Hi-Fi LM3886 5.1 channel amplifier, with digital control - Control Android Bluetooth Application

Digital Control - Android App

On this page...

App introduction

Update: As of July 2022, I've removed Bluetooth RS232 in my amp to reduce idle power consumption, therefore I no longer use this app. Things move fast in the smart phone world too, but my app still worked fine in Android 12 prior to stopping its use. It has only been tested on a OnePlus 5 and a OnePlus Nord. You can consider Kotlin as the preferred choice for Android development as of 2019, info below will still be of interest though and Java is still supported and a great programming language (IMO).

Full code for the app is on github.com - see AndroidApp folder, with explanations and info below.

Throughout my builds, I'd always provided some 'alternative' form of control. In the early days it was via an RS232 serial cable plugged into my PC and a Java desktop application - but that barely got used and became useless as PCs started to come without RS232 ports, and the system became more bound to my TV then my PC.

I did briefly consider using the USB features of the port to replace the RS232 communication, but the rise of Bluetooth got me interested and Bluetooth quite nicely has a specification for RS232/serial communications.

I did start to look at Java applications for the 'feature' phones of those days, but the rise of the Smart-phone really changed things and I've used an Android phone since 2009 (now on my fifth as of 2022).

They've all had Bluetooth support, but completion of the app was something I only achieved in 2018, even though prototypes started some time ago. Therefore, this app is targeted to more recent Android versions, but in theory (with some code changes), it could be made to support older phones.

Having an Android application means I can place those extra settings that are not available on the IR remote into the application, and use 'touch' to adjust the settings in a far more convenient way than I've seen on many complex remote controls.

Here I will show some information about how I used Android Studio to design and code an application that is lightweight, requires few permissions and convenient to use.

Tools

There is a choice of tools you can do this in.

My choice however is Android Studio. The reason is because the community and info out on the web is very strong and anything you want to do, an Internet search and chances are someone has asked the same. Tutorials are also good. Android Studio however is quite a beast, so expect it to take your entire evening to download, install and update.

Alternatives include Visual Studio (with Xamarin) if you want to try in C#. I think this is great for a generic app targeting Android, iOS and Windows Phone, but for Bluetooth I found the information was a bit lacking and definitely not multi-platform.

For Android only, some existing Java developers might prefer to work in Eclipse. My first prototype was done in Eclipse and the tools worked OK.

For iOS, there is a problem, and Apple won't let you connect to a JY-MCU HC-06 or many other Bluetooth RS232 adaptors. You have to use very specific Bluetooth adaptors accepted in MFi (that naturally cost more - loads more!). Assuming you do that, the tools to build an app will be different and much of what I write below will be less use. There are no Apple devices in my home, so I've never experimented with Objective-C or Swift, but I'm sure someone has and if you wanted to build an amp like mine and control it from an iOS device, I'm sure it is possible.

For those who are happy with a phone OS that gives you more freedom - read on!

User Interface

I somewhat started on a top-down approach to the implementation of the app. That means putting buttons and sliders where I wanted and then adding the code for what I wanted them to do later. This may actually be a good way to build phone apps - because the user interface is not just what is displayed, it is what you touch too.

Application main page look and feel
Application main page look and feel

My UI I believe is built to be simple, looks good enough and isn't over-complicated or too flash. It's not going into Google Play and exists only for me, so I don't have to worry about the world giving me reviews for it! For me though, I'm pleased with how I got it to work and the tips I'll list can be applied to real-world apps too!

To get this interface simple - I'm using RelativeLayout for both the main view, and the advanced controls. This is a simple layout that is quite tolerant to phone screens of different sizes and easy to use. I've only tested the layout on a OnePlus 5 phone though, but this has quite a typical screen resolution and size/ratio of most phones these days.

The advanced controls I did not want visible all the time, but rather than place them in a different 'activity' (needed to show a different page in the app), I used the component com.github.aakira.expandablelayout.ExpandableRelativeLayout, found from here.

This allows me to hide the advanced controls by default, and still keep all the code nice and simple in a single Activity.

Application with expanded advanced controls
Application with expanded advanced controls

During use, all controls are disabled until both the Bluetooth is connected, and if the Power is off, then again, all the controls (except the Power button) are disabled too.

Application startup with controls disabled, except Bluetooth button
Application startup with controls disabled, except Bluetooth button

The SeekBar is used for the volume, which is a nice way to drag the volume up or down, but you can also touch anywhere on the seek bar which could cause insane jumps in volume on the amplifier. To disable this, in code I prevent the SeekBar from changing if the position from where it was to where it is moved to is larger than +/- 10 steps (translates to +/- 5dB), then the change is ignored.

Finally, ProgressDialog is used in two cases. One being a wait message whilst the Bluetooth does its connection, and the other being the power on/off delay.

Connecting to Bluetooth progress dialog
Connecting to Bluetooth progress dialog

The layouts are controlled by three xml files

  • activity_main.xml - the parent activity layout
  • content_home.xml - the top panel with the standard controls
  • content_advanced.xml - the hidden panel with the advanced controls
activity_main.xml

This is the main layout, where the toolbar is specified and a scroll layout to allow the overall content to scroll if required. LinearLayout is then used for the placement of the content_home panel (standard controls) followed by the com.github.aakira.expandablelayout.ExpandableRelativeLayout control, used to show/hide advanced controls

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/activity_main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context="uk.co.electro_dan.ampcontrol.MainActivity">

    <android.support.v7.widget.Toolbar
        xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:id="@+id/toolBar"
        android:background="?attr/colorPrimary"
        android:minHeight="?attr/actionBarSize"
        android:fitsSystemWindows="true"
        app:title="Amplifier Control"
        android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar">

    </android.support.v7.widget.Toolbar>

    <ScrollView
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:orientation="vertical">

            <include layout="@layout/content_home" />

            <Button
                android:id="@+id/expandableButton1"
                android:layout_width="fill_parent"
                android:layout_height="wrap_content"
                android:layout_alignParentBottom="true"
                android:layout_alignParentStart="true"
                android:drawableRight="@android:drawable/arrow_down_float"
                android:text="Advanced settings" />

            <com.github.aakira.expandablelayout.ExpandableRelativeLayout
                android:id="@+id/expandableLayout1"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:layout_below="@+id/expandableButton1"
                android:padding="16dp"
                app:ael_duration="400"
                app:ael_expanded="false"
                app:ael_interpolator="accelerate"
                app:ael_orientation="vertical">

                <include layout="@layout/content_advanced" />

            </com.github.aakira.expandablelayout.ExpandableRelativeLayout>


        </LinearLayout>

    </ScrollView>


</LinearLayout>

content_home.xml

This is the standard layout. Here a number of TextView, ToggleButton, SeekBar, Button and RadioButton controls are used to build the layout of the standard interface, using a RelativeLayout.

Most of this xml was just generated by Android Studio when dragging and dropping the controls on the preview pane. In some cases, I then fine-tuned the padding and layout snapping.

The controls on here are:

  • ToggleButton for the Bluetooth connection on or off. Toggling on will initiate the Bluetooth connection process.
  • TextView to display the selected Input - e.g., PC (5.1 Input)
  • SeekBar for volume, with TextView to display the current level in dB and Button controls to make small volume up/down increments as an alternative to the slider
  • RadioButtonGroup of 5 RadioButton controls to choose one of four inputs
  • ToggleButton for Power on/off
  • ToggleButton for Mute on/off
<?xml version="1.0" encoding="utf-8"?>

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    app:layout_behavior="@string/appbar_scrolling_view_behavior"
    tools:context="uk.co.electro_dan.ampcontrol.MainActivity">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textAppearance="?android:attr/textAppearanceMedium"
        android:text="Bluetooth"
        android:id="@+id/textView3"
        android:layout_alignParentLeft="true"
        android:layout_alignParentStart="true" />

    <TextView
        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignStart="@+id/togglePower"
        android:layout_alignTop="@+id/radioGroup1"
        android:layout_marginTop="0dp"
        android:text="Power"
        android:textAppearance="?android:attr/textAppearanceMedium" />

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textAppearance="?android:attr/textAppearanceMedium"
        android:text="Mute"
        android:id="@+id/textView5"
        android:layout_above="@+id/toggleMute"
        android:layout_alignLeft="@+id/toggleMute"
        android:layout_alignStart="@+id/toggleMute" />

    <ToggleButton
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Connect"
        android:id="@+id/toggleConnect"
        android:layout_below="@+id/textView3"
        android:layout_alignParentLeft="true"
        android:layout_alignParentStart="true" />

    <ToggleButton
        android:id="@+id/togglePower"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_below="@+id/textView"
        android:layout_marginStart="15dp"
        android:layout_marginTop="0dp"
        android:layout_toEndOf="@+id/radioGroup1"
        android:checked="false"
        android:text="Power" />

    <ToggleButton
        android:id="@+id/toggleMute"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignBaseline="@+id/togglePower"
        android:layout_alignBottom="@+id/togglePower"
        android:layout_marginStart="15dp"
        android:layout_toEndOf="@+id/togglePower"
        android:checked="false"
        android:text="Mute" />

    <TextView
        android:id="@+id/textView4"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignStart="@+id/textView"
        android:layout_alignTop="@+id/toggleConnect"
        android:text="PC (5.1 Input)"
        android:textSize="24dp"
        android:textAppearance="?android:attr/textAppearanceMedium" />

    <TextView
        android:id="@+id/textView6"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentStart="true"
        android:layout_below="@+id/toggleConnect"
        android:layout_marginTop="10dp"
        android:textStyle="bold"
        android:text="Volume"
        android:textAppearance="?android:attr/textAppearanceMedium" />

    <Button
        android:id="@+id/buttonVolDown"
        android:layout_width="40dp"
        android:layout_height="wrap_content"
        android:layout_alignParentLeft="true"
        android:layout_alignParentStart="true"
        android:layout_alignStart="@+id/textView3"
        android:layout_below="@+id/textView6"
        android:text="-" />

    <Button
        android:id="@+id/buttonVolUp"
        android:layout_width="41dp"
        android:layout_height="wrap_content"
        android:layout_alignParentEnd="true"
        android:layout_alignParentRight="true"
        android:layout_below="@+id/textView6"
        android:text="+" />

    <TextView
        android:id="@+id/txtVol"
        android:layout_width="75dp"
        android:layout_height="wrap_content"
        android:layout_below="@+id/textView6"
        android:layout_centerHorizontal="true"
        android:text="-96.0dB"
        android:textAppearance="?android:attr/textAppearanceMedium" />

    <SeekBar
        android:id="@+id/seekBar"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_below="@+id/txtVol"
        android:layout_marginLeft="1dp"
        android:layout_marginRight="1dp"
        android:layout_toLeftOf="@+id/buttonVolUp"
        android:layout_toRightOf="@+id/buttonVolDown"
        android:indeterminate="false"
        android:max="255"
        android:progress="0" />

    <RadioGroup
        android:id="@+id/radioGroup1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentTop="true"
        android:layout_alignStart="@+id/textView3"
        android:layout_marginTop="180dp"
        android:orientation="vertical">

        <RadioButton
            android:id="@+id/radioButton1"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_alignParentLeft="true"
            android:layout_alignParentStart="true"
            android:layout_below="@+id/txtVol"
            android:layout_marginTop="00dp"
            android:checked="true"
            android:text="PC (5.1 Input)" />

        <RadioButton
            android:id="@+id/radioButton2"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_alignParentLeft="true"
            android:layout_alignParentStart="true"
            android:layout_below="@+id/radioButton1"
            android:checked="false"
            android:text="Television" />

        <RadioButton
            android:id="@+id/radioButton3"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_alignParentLeft="true"
            android:layout_alignParentStart="true"
            android:layout_below="@+id/radioButton2"
            android:checked="false"
            android:text="Phono" />

        <RadioButton
            android:id="@+id/radioButton4"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_alignParentLeft="true"
            android:layout_alignParentStart="true"
            android:layout_below="@+id/radioButton3"
            android:checked="false"
            android:text="Auxiliary" />

    </RadioGroup>

</RelativeLayout>

content_advanced.xml

This is the advanced layout. Here a number of TextView, CheckBox and SeekBar controls are used to build the layout of the advanced interface, again using RelativeLayout.

Again, most of this xml was just generated by Android Studio when dragging and dropping the controls on the preview pane, with manual fine tuning.

The controls on here are:

  • 4 CheckBox controls for switching between 'Hafler' surround (using ESP Project 18) or just stereo only for the stereo inputs, a 5.1 check box for switching between 5.1 external decoding or the 'Halfer' decoding, and two on/off controls for the two 12V trigger outputs
  • 5 SeekBar controls, with associated TextView to display the current setting. These are front balance, rear speaker adjustment, rear speaker balance, centre speaker adjustment and subwoofer adjustment
<?xml version="1.0" encoding="utf-8"?>

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingBottom="0dp"
    android:paddingLeft="0dp"
    android:paddingRight="0dp"
    android:paddingTop="@dimen/activity_vertical_margin"
    app:layout_behavior="@string/appbar_scrolling_view_behavior">


    <CheckBox
        android:id="@+id/check51Mode"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentTop="true"
        android:layout_toEndOf="@+id/textViewSubAdj"
        android:checked="true"
        android:text="5.1 Mode" />

    <CheckBox
        android:id="@+id/checkHafler"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentStart="true"
        android:layout_alignParentTop="true"
        android:checked="true"
        android:text="Hafler Surround" />

    <CheckBox
        android:id="@+id/checkTrigger1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentStart="true"
        android:layout_below="@+id/check51Mode"
        android:checked="false"
        android:text="Trigger 1" />

    <CheckBox
        android:id="@+id/checkTrigger2"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignStart="@+id/check51Mode"
        android:layout_below="@+id/check51Mode"
        android:checked="false"
        android:text="Trigger 2" />

    <TextView
        android:id="@+id/textViewFrontBal"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentStart="true"
        android:layout_below="@+id/checkTrigger2"
        android:layout_marginTop="20dp"
        android:text="Front Balance"
        android:textAppearance="?android:attr/textAppearanceMedium" />

    <TextView
        android:layout_width="75dp"
        android:layout_height="wrap_content"
        android:textAppearance="?android:attr/textAppearanceMedium"
        android:text="0dB"
        android:id="@+id/txtFrontBal"
        android:layout_below="@+id/textViewFrontBal"
        android:layout_alignParentLeft="true"
        android:layout_alignParentStart="true" />

    <SeekBar
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/seekBarFrontBal"
        android:max="63"
        android:progress="31"
        android:indeterminate="false"
        android:layout_alignTop="@+id/txtFrontBal"
        android:layout_alignParentRight="true"
        android:layout_alignParentEnd="true"
        android:layout_toRightOf="@+id/txtFrontBal"
        android:layout_toEndOf="@+id/txtFrontBal" />

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textAppearance="?android:attr/textAppearanceMedium"
        android:text="Rear Adjust"
        android:id="@+id/textViewRearAdj"
        android:layout_below="@+id/txtFrontBal"
        android:layout_alignParentLeft="true"
        android:layout_alignParentStart="true"
        android:layout_marginTop="20dp" />

    <TextView
        android:layout_width="75dp"
        android:layout_height="wrap_content"
        android:textAppearance="?android:attr/textAppearanceMedium"
        android:text="0dB"
        android:id="@+id/txtRearAdj"
        android:layout_below="@+id/textViewRearAdj"
        android:layout_alignParentLeft="true"
        android:layout_alignParentStart="true" />

    <SeekBar
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/seekBarRearAdj"
        android:max="63"
        android:progress="31"
        android:indeterminate="false"
        android:layout_alignTop="@+id/txtRearAdj"
        android:layout_alignParentRight="true"
        android:layout_alignParentEnd="true"
        android:layout_toRightOf="@+id/txtRearAdj"
        android:layout_toEndOf="@+id/txtRearAdj" />

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textAppearance="?android:attr/textAppearanceMedium"
        android:text="Rear Balance"
        android:id="@+id/textViewRearBal"
        android:layout_below="@+id/txtRearAdj"
        android:layout_alignParentLeft="true"
        android:layout_alignParentStart="true"
        android:layout_marginTop="20dp" />

    <TextView
        android:layout_width="75dp"
        android:layout_height="wrap_content"
        android:textAppearance="?android:attr/textAppearanceMedium"
        android:text="0dB"
        android:id="@+id/txtRearBal"
        android:layout_below="@+id/textViewRearBal"
        android:layout_alignParentLeft="true"
        android:layout_alignParentStart="true" />

    <SeekBar
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/seekBarRearBal"
        android:max="63"
        android:progress="31"
        android:indeterminate="false"
        android:layout_alignTop="@+id/txtRearBal"
        android:layout_alignParentRight="true"
        android:layout_alignParentEnd="true"
        android:layout_toRightOf="@+id/txtRearBal"
        android:layout_toEndOf="@+id/txtRearBal" />

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textAppearance="?android:attr/textAppearanceMedium"
        android:text="Centre Adjust"
        android:id="@+id/textViewCentreAdj"
        android:layout_below="@+id/txtRearBal"
        android:layout_alignParentLeft="true"
        android:layout_alignParentStart="true"
        android:layout_marginTop="20dp" />

    <TextView
        android:layout_width="75dp"
        android:layout_height="wrap_content"
        android:textAppearance="?android:attr/textAppearanceMedium"
        android:text="0dB"
        android:id="@+id/txtCentreAdj"
        android:layout_below="@+id/textViewCentreAdj"
        android:layout_alignParentLeft="true"
        android:layout_alignParentStart="true" />

    <SeekBar
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/seekBarCentreAdj"
        android:max="63"
        android:progress="31"
        android:indeterminate="false"
        android:layout_alignTop="@+id/txtCentreAdj"
        android:layout_alignParentRight="true"
        android:layout_alignParentEnd="true"
        android:layout_toRightOf="@+id/txtCentreAdj"
        android:layout_toEndOf="@+id/txtCentreAdj" />

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textAppearance="?android:attr/textAppearanceMedium"
        android:text="Subwoofer Adjust"
        android:id="@+id/textViewSubAdj"
        android:layout_below="@+id/txtCentreAdj"
        android:layout_alignParentLeft="true"
        android:layout_alignParentStart="true"
        android:layout_marginTop="20dp" />

    <TextView
        android:layout_width="75dp"
        android:layout_height="wrap_content"
        android:textAppearance="?android:attr/textAppearanceMedium"
        android:text="0dB"
        android:id="@+id/txtSubAdj"
        android:layout_below="@+id/textViewSubAdj"
        android:layout_alignParentLeft="true"
        android:layout_alignParentStart="true" />

    <SeekBar
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/seekBarSubAdj"
        android:max="63"
        android:progress="31"
        android:indeterminate="false"
        android:layout_alignTop="@+id/txtSubAdj"
        android:layout_alignParentRight="true"
        android:layout_alignParentEnd="true"
        android:layout_toRightOf="@+id/txtSubAdj"
        android:layout_toEndOf="@+id/txtSubAdj"
        android:layout_marginBottom="60dp"/>


</RelativeLayout>

That covers all the controls and their layout on the app view.

Coding

The main code is in a single Java file (because it's just a single Activity app) and comes in at just over 1000 lines of code. That's not too cumbersome!

The layout of the code is:

  • Imports at the top - specifying all the libraries we need
  • A single class - MainActivity, which extends the Android AppCompatActivity class, and in this class...
  • Private variables for the UI elements, and the amplifier variables
  • The onCreate method (overrides the default one in AppCompatActivity) - here the layout is chosen (from the xml activity_main), the UI variables are assigned and listeners for touch actions are created
  • The setUiEnabled method - enables or disables the interface
  • Bluetooth methods - btInit, onActivityResult, btBond, btConnect, btConnectionHandler, beginListenForData, btReceivedUpdateHandler, sendMessageToAmp, rs232SendNibble, rs232ReceiveNibble
  • UI methods - updateLevelDisplay, setRadioInput, doPowerChange, updateFrontBalance, updateRearBalance, updateRearAdjust, updateCentreAdjust, updateSubAdjust

Below I'll describe a little on how each section works.

Android Lifecycle methods - onCreate

Android Activities (pages in an App) have a Lifecycle - onCreate() -> onStart() -> onResume() -> onPause() -> onStop() -> onDestroy()

All of these methods exist in the Activity class that our class will extend (AppCompatActivity in my case). All of these methods can be overridden.

The 'extends' keyword means that our class is going to extend all the code that already exists (thanks to Google and so) for Activity. The 'extends' keyword is a feature known as Inheritance in Object Oriented Programming. That means in my case, the class MainActivity contains all the methods the Activity (or AppCompatActivity) class has.

The @Override methods is an example of Polymorphism in Object Oriented Programming. It's a way of saying 'in my class, do this instead'. The 'super' keyword in our overrides methods allows them to still call the original code in the class we extend before/after code we want to do.

The onCreate() method is the one that is most commonly overridden method, because here we will want to initialise all our controls.

I've used it to do three things - assign the private variables in my class the actual controls, disable them all, and for each control that needs it, assign a 'listener' by overriding the default methods in those objects.

For example, my toggleConnect control is a ToggleButton and its toggleConnect property can be assigned an OnClickListener class. The onClick() method in that class is then overridden and I do the code I want when a user clicks the button.

The code I do for toggleConnect being clicked is to initialise the Bluetooth connection process if it is toggled to true or disconnect the Bluetooth connection if it is toggled to false.

        // This is the action when toggling to connect to the bluetooth adapter
        toggleConnect.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View arg0) {
                if (toggleConnect.isChecked()) {
                    // If the user checked to connect...
                    // Show connecting dialog
                    pDialog = ProgressDialog.show(MainActivity.this, "Connecting", "Connecting to bluetooth, please wait...", true);
                    // Call the initialisation routine
                    btInit();
                } else {
                    // If the user checked to disconnect...
                    // Set disconnected
                    isConnected = false;
                    // Stop the listener thread
                    doStopListenThread = true;
                    // Disable the GUI
                    setUiEnabled(false);
                    // Close the bluetooth objects
                    try {
                        if (outputStream != null) outputStream.close();
                        if (inputStream != null) inputStream.close();
                        if (btSocket != null) btSocket.close();
                    } catch (IOException ioe) {
                        Log.e(TAG, "Can't disconnect", ioe);
                    }
                }
            }
        });

For the volume, I have listener for the volume down button (decrements the volume by two, stopping at zero), volume up button (increments the volume by two, stopping at 255), and a handler for the volume slider being dragged.

All of these methods call updateLevelDisplay(), which I'll show later, and this handles volume changes, and can send the new volume to the amplifier.

        // Add click handler to volume down button
        buttonVolDown.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View arg0) {
                // Toggle advanced settings
                iVolLevel -= 2;
                if (iVolLevel < 0)
                    iVolLevel = 0;
                // First call sets the slider
                updateLevelDisplay(false);
                // Second call sends the message
                updateLevelDisplay(true);
            }
        });

        // Add click handler to volume down button
        buttonVolUp.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View arg0) {
                // Toggle advanced settings
                iVolLevel += 2;
                if (iVolLevel > 255)
                    iVolLevel = 255;
                // First call sets the slider
                updateLevelDisplay(false);
                // Second call sends the message
                updateLevelDisplay(true);
            }
        });
        
        // Add a change listener to the seek bar
        seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
            public void onStopTrackingTouch(SeekBar bar) {
            }
            public void onStartTrackingTouch(SeekBar bar) {
            }
            public void onProgressChanged(SeekBar bar,int iProgress, boolean fromUser) {
                // Un-mute first
                if (toggleMute.isChecked()) {
                    // Un-mute if slider is dragged
                    toggleMute.setChecked(false);
                    iMute = 0;
                    sendMessageToAmp(R.string.msgMuteSend);
                    try {
                        Thread.sleep(200);
                    } catch (InterruptedException mie) {
                        Log.i("UI", "SeekBar sleep thread was interrupted");
                    }
                }
                updateLevelDisplay(fromUser);
            }
        });

My RadioGroup needs a handler when the user touches a new radio button. This sets the TextView to the active radio button text, sets the iActiveInput variable to the correct state and sends the new state to the amplifier.

        // Add change listener to the radio button group
        radiogroup.setOnCheckedChangeListener(new RadioGroup.OnCheckedChangeListener() {
            public void onCheckedChanged(RadioGroup group, int checkedId) {

                RadioButton checkedRadioButton = (RadioButton)findViewById(checkedId);
                TextView txtInputSelected = (TextView)findViewById(R.id.textView4);

                if ((checkedRadioButton != null) && (txtInputSelected != null)) {
                    // This switch comparing each radio ID against the checkedId is more reliable than getRadioInput
                    if (checkedRadioButton.isChecked()) {
                        switch (checkedId) {
                            case R.id.radioButton1:
                                iActiveInput = 0;
                                break;
                            case R.id.radioButton2:
                                iActiveInput = 1;
                                break;
                            case R.id.radioButton3:
                                iActiveInput = 2;
                                break;
                            case R.id.radioButton4:
                                iActiveInput = 3;
                                break;
                        }
                    }

                    txtInputSelected.setText(checkedRadioButton.getText());

                    // Set state to write relay
                    if (toggleConnect.isChecked()) {
                        sendMessageToAmp(R.string.msgInputSend);
                    }
                }
            }
        });

The togglePower ToggleButton calls my separate doPowerChange() method.

        // Add click handler to the input settings button
        togglePower.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View arg0) {
                // Change power status
                doPowerChange();
            }
        });

The toggleMute ToggleButton sets the iMute variable to 0 or 1, and then sends that to the amplifier.

        toggleMute.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View arg0) {
                // Mute
                if (toggleConnect.isChecked()) {
                    if (toggleMute.isChecked())
                        iMute = 1;
                    else
                        iMute = 0;
                    sendMessageToAmp(R.string.msgMuteSend);
                }
            }
        });

The expandableButton1 Button toggles the ExpandableRelativeLayout visible or not visible.

        // Add click handler to expand button, for advanced settings
        expandableButton1.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View arg0) {
                // Toggle advanced settings
                expandableLayout1 = (ExpandableRelativeLayout) findViewById(R.id.expandableLayout1);
                if (expandableLayout1 != null)
                    expandableLayout1.toggle(); // toggle expand and collapse
            }
        });

The checkHafler, checkTrigger1, checkTrigger2 and check51Mode are all CheckBox controls with similar handlers. These handlers set iSurroundMode, iTrigger or iExtSurroundMode and then send that relevant state to the amplifier.

The trigger ones are slightly different - because the iTrigger integer is used to control the outputs of my two triggers, it will contain the bits 00 (both off), 01 (trigger 1 only), 10 (trigger 2 only) or 11 (both on). Bitwise operators |= and &= are used to set or clear the relevant bits.

        // Add click handler to the Hafler button
        checkHafler.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View arg0) {
                // Toggle surround
                if (toggleConnect.isChecked()) {
                    if (checkHafler.isChecked())
                        iSurroundMode = 1;
                    else
                        iSurroundMode = 0;
                    sendMessageToAmp(R.string.msgSurroundSend);
                }
            }
        });

        // Add click handler to the 5.1 mode button
        check51Mode.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View arg0) {
                // Toggle surround
                if (toggleConnect.isChecked()) {
                    if (check51Mode.isChecked())
                        iExtSurroundMode = 1;
                    else
                        iExtSurroundMode = 0;
                    sendMessageToAmp(R.string.msgExtSurroundSend);
                }
            }
        });

        // Add click handler to the Hafler button
        checkTrigger1.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View arg0) {
                // Toggle surround
                if (toggleConnect.isChecked()) {
                    if (checkTrigger1.isChecked())
                        iTrigger |= 1; // Set bit 0
                    else
                        iTrigger &= 2; // Clear bit 1
                    sendMessageToAmp(R.string.msgTriggerSend);
                }
            }
        });

        // Add click handler to the Hafler button
        checkTrigger2.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View arg0) {
                // Toggle surround
                if (toggleConnect.isChecked()) {
                    if (checkTrigger2.isChecked())
                        iTrigger |= 2; // Set bit 1
                    else
                        iTrigger &= 1; // Clear bit 1
                    sendMessageToAmp(R.string.msgTriggerSend);
                }
            }
        });

Finally, the SeekBar's for the volume or balance adjustments for front, rear, centre and sub need handlers too. These call separate methods in my class. Below is the example listener for frontBalance only:

        // Add a change listener to the seek bar
        seekBarFrontBal.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
            public void onStopTrackingTouch(SeekBar bar) {
            }
            public void onStartTrackingTouch(SeekBar bar) {
            }
            public void onProgressChanged(SeekBar bar,int iProgress, boolean fromUser) {
                updateFrontBalance(fromUser);
            }
        });

The onCreate() method is the only one I override in the Activity Lifecycle. I did not want to do anything when the application is paused - the App stays alive and connected to the amplifier (if it was already) if I switch to another App. This makes sense to me because I might want to flip between my Amp control app, then to (say) an App that is casting radio, music, video to the Chromecast or TV, and then switch back to my Amp control without having to reconnect.

The impact on the battery on leaving it connected and alive will be something, but it does not seem to be really noticeable!

Disabling and enabling the controls

Most the controls should not operate unless the amplifier is connected via Bluetooth, and the Power is on.

The following method will disable all the controls (except the Bluetooth connect control) if Bluetooth is not connected and disable all controls except the Power button if the power is off.

    // Disables or enables the interface based on the bluetooth connection
    public void setUiEnabled(boolean bool) {
        togglePower.setEnabled(bool);

        // For the rest of the components, make false if power is off
        bool = bool && (iPower == 1);

        seekBar.setEnabled(bool);
        buttonVolDown.setEnabled(bool);
        buttonVolUp.setEnabled(bool);
        toggleMute.setEnabled(bool);
        radiogroup.setEnabled(bool);
        for (int i = 0; i < radiogroup.getChildCount(); i++) {
            RadioButton btn = (RadioButton) radiogroup.getChildAt(i);
            btn.setEnabled(bool);
        }
        check51Mode.setEnabled(bool);
        checkHafler.setEnabled(bool);
        checkTrigger1.setEnabled(bool);
        checkTrigger2.setEnabled(bool);
        seekBarFrontBal.setEnabled(bool);
        seekBarRearBal.setEnabled(bool);
        seekBarRearAdj.setEnabled(bool);
        seekBarCentreAdj.setEnabled(bool);
        seekBarSubAdj.setEnabled(bool);
    }

Bluetooth Methods

Bluetooth initialisation and connecting was something that was a bit of a challenge. The result is thanks to information I found online, but I've enhanced it to not hang the UI whilst connecting (avoiding the 'skipped frames' warning in ADB) and display a 'Connecting' progress dialog when it is operating.

For long operations, I'm using these Message and Handler classes, so that a separate Thread can send a Message back to the main Activity and cause actions to happen there. This is a little bit of multi-threading programming in action!

The connection process will be a short-lived thread that takes a few seconds usually. I do it in a Thread, so it doesn't make the App look like it is hanging whilst the connection process is occurring in the Thread itself, and the spinning circle of the progress dialog still spins! Otherwise, you initially get the warning something like 'Skipped 300 frames! The application may be doing too much work on its main thread.'.

Once the connection is made - another Thread is used to listen for incoming data from the Amp. Since this Thread is listening for data constantly - it will loop indefinitely until the Thread is destroyed by disconnecting the Bluetooth, or closing the whole App. This is quite different from the 'Interrupt' approach in the procedural programming on the PIC, so you can see how you have to approach requirements differently.

These Threads cannot directly update the UI though, so that's why Message and Handlers are used so the UI can be told it needs to do something by the separate Thread.

The code below are the methods used for the connection process.

    // Initialisation method for bluetooth. Will check bluetooth support, if present, requests it to be enabled. If enabled, goes straight to bond.
    public void btInit() {
        BluetoothAdapter btAdapter = BluetoothAdapter.getDefaultAdapter();
        if (btAdapter == null) {
            Toast.makeText(getApplicationContext(), "Device doesn't Support Bluetooth", Toast.LENGTH_SHORT).show();
        } else if (!btAdapter.isEnabled()) {
            Intent intentEnableAdapter = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE);
            startActivityForResult(intentEnableAdapter, 0);
        } else {
            btBond();
        }
    }

    // This is called after the user has enabled bluetooth
    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data){
        super.onActivityResult(requestCode, resultCode, data);
        if (resultCode == RESULT_OK) {
            Log.i("Bluetooth", "Attempting to bond");
            // Now bluetooth is enabled, bond the device
            btBond();
        } else if (resultCode == RESULT_CANCELED) {
            if (pDialog != null)
                pDialog.dismiss();
            Toast.makeText(getApplicationContext(), "Enabling bluetooth cancelled", Toast.LENGTH_SHORT).show();
            toggleConnect.setChecked(false);
        }
    }

    // Checks bonded devices. Request to pair made here
    public void btBond() {
        boolean isFound = false;
        BluetoothAdapter btAdapter = BluetoothAdapter.getDefaultAdapter();
        Set<BluetoothDevice> bondedDevices = btAdapter.getBondedDevices();
        if (bondedDevices.isEmpty()) {
            Toast.makeText(getApplicationContext(), "Please Pair the Device first", Toast.LENGTH_SHORT).show();
            if (pDialog != null)
                pDialog.dismiss();
        } else {
            Log.i("Bluetooth", "Checking bonded devices");
            for (BluetoothDevice iterator : bondedDevices) {
                // If we found a paired device named 'Amp', we can connect to this one
                if (iterator.getName().equals(DEVICE_NAME)) {
                    btDevice = iterator;
                    isFound = true;
                    Log.i("Bluetooth", "Found " + DEVICE_NAME);
                    break;
                }
            }
        }
        // If we found 'Amp' is paired, now connect
        if (isFound)
            btConnect();
    }


    // Connect to the RF comm interface
    public void btConnect() {
        // Connect in a thread to prevent locking the GUI
        new Thread() {
            @Override
            public void run() {

                try {
                    btSocket = btDevice.createRfcommSocketToServiceRecord(PORT_UUID);
                    btSocket.connect();
                    isConnected = true;
                } catch (IOException e) {
                    e.printStackTrace();
                }
                // If we connected OK - get the input and output steams
                if (isConnected) {
                    try {
                        outputStream = btSocket.getOutputStream();
                    } catch (IOException e) {
                        isConnected = false;
                        e.printStackTrace();
                    }
                    try {
                        inputStream = btSocket.getInputStream();
                    } catch (IOException e) {
                        isConnected = false;
                        e.printStackTrace();
                    }
                }

                // Once connection is complete, send back to the handler
                Message msg = btConnectionHandler.obtainMessage();
                btConnectionHandler.sendMessage(msg);
            }
        }.start();
    }

    // Handles a completed bluetooth connection
    private final Handler btConnectionHandler = new Handler(new Handler.Callback() {
        @Override
        public boolean handleMessage(Message msg) {
            // Dismiss the connecting... dialog
            if (pDialog != null)
                pDialog.dismiss();

            // Based on connection
            if (isConnected) {
                // Enable the GUI
                toggleConnect.setChecked(true);
                //setUiEnabled(true); - now done in the receive handler based on power status of amp
                doStopListenThread = false;
                beginListenForData();
                // Get the status from the Amp
                sendMessageToAmp(R.string.msgGetStatus);
            } else {
                // If not connected, disable GUI
                toggleConnect.setChecked(false);
                setUiEnabled(false);
                // Show a message
                Toast.makeText(getApplicationContext(), "Could not connect to amplifier", Toast.LENGTH_SHORT).show();
            }
            return false;
        }
    });

With the code above, the entire flow is following this stream:

  1. btInit() - this is the first method called from the toggleConnect button. It checks if Bluetooth is enabled. If it is not, it creates an Activity with Intent to enable Bluetooth. If Bluetooth is already enabled, it jumps straight to the btBond() method, step 3
  2. onActivityResult() - this handles the result of the activity that was created in step 1 (if Bluetooth was not Enabled). If the user clicks 'Allow', the connection process can continue and btBond() is run. If the user clicked cancel, the connection process stops here, and the application remains unconnected.
  3. btBond() - this is the method that bonds the application to the Bluetooth device. To do that, it needs to be paired first. If the user has not paired the device, a notification will appear on the notification bar, and the connection process ends here. If it is already paired, the code searches the paired devices for the Bluetooth device named 'Amp' and sets the global Class variable 'btDevice' to be that one.
  4. btConnect() - this makes the connection to the Bluetooth adapter. Because this can take a second or two, it's Threaded. The Thread will make the connection, and then set inputStream and outputStream global Class variables, enabling the app to read data received, and transmit data back. Once the connection is complete, it creates a Message, which is used to notify the main application thread that the connection is complete
  5. Handler btConnectionHandler - this is the receiver of the message raised in step 4 above. It dismisses the 'Please wait' dialog and, if connected, enables the UI and starts a 'listen' Thread which will be used to continuously check
Bluetooth - sending data

Transmitting data is done by each relevant UI interaction. This causing a command letter (byte) to be sent, followed by the subsequent bytes to send the required value.

The methods below are common to handle the sending of data to the amplifier.

    // Send a message to the amp via bluetooth
    private void sendMessageToAmp(int arg1) {
        if (toggleConnect.isChecked()) {
            // Build correct message
            StringBuilder sbOut = new StringBuilder();
            sbOut.append("Amp1:"); // Key

            // Depending on the argument, sends the command indicator based on the shared strings, and then the command value
            switch (arg1) {
                case R.string.msgGetStatus :
                    // Just send G
                    sbOut.append(getResources().getString(arg1));
                    break;
                case R.string.msgPowerSend :
                    // Send P followed by power value
                    sbOut.append(getResources().getString(arg1));
                    sbOut.append(iPower);
                    break;
                case R.string.msgMuteSend :
                    // Send P followed by power value
                    sbOut.append(getResources().getString(arg1));
                    sbOut.append(iMute);
                    break;
                case R.string.msgVolumeSend :
                    // Send V followed by two bytes representing volume nibbles
                    sbOut.append(getResources().getString(arg1));
                    sbOut.append(rs232SendNibble(iVolLevel));
                    break;
                case R.string.msgInputSend :
                    // Send I followed by input value
                    sbOut.append(getResources().getString(arg1));
                    sbOut.append(iActiveInput);
                    break;
                case R.string.msgFrontBalanceSend :
                    // Send F followed by two bytes representing volume nibbles
                    sbOut.append(getResources().getString(arg1));
                    sbOut.append(rs232SendNibble(iFrontBalance));
                    break;
                case R.string.msgRearBalanceSend :
                    // Send R followed by two bytes representing volume nibbles
                    sbOut.append(getResources().getString(arg1));
                    sbOut.append(rs232SendNibble(iRearBalance));
                    break;
                case R.string.msgRearAdjustSend :
                    // Send r followed by two bytes representing volume nibbles
                    sbOut.append(getResources().getString(arg1));
                    sbOut.append(rs232SendNibble(iRearAdjust));
                    break;
                case R.string.msgCenAdjustSend :
                    // Send C followed by two bytes representing volume nibbles
                    sbOut.append(getResources().getString(arg1));
                    sbOut.append(rs232SendNibble(iCentreAdjust));
                    break;
                case R.string.msgSubAdjustSend :
                    // Send S followed by two bytes representing volume nibbles
                    sbOut.append(getResources().getString(arg1));
                    sbOut.append(rs232SendNibble(iSubAdjust));
                    break;
                case R.string.msgSurroundSend :
                    // Send M followed by new value
                    sbOut.append(getResources().getString(arg1));
                    sbOut.append(iSurroundMode);
                    break;
                case R.string.msgExtSurroundSend :
                    // Send E followed by new value
                    sbOut.append(getResources().getString(arg1));
                    sbOut.append(iExtSurroundMode);
                    break;
                case R.string.msgTriggerSend :
                    // Send T followed by new value
                    sbOut.append(getResources().getString(arg1));
                    sbOut.append(iTrigger);
                    break;
            }

            // Send end of message indicator
            sbOut.append("\n");

            try {
                // Write the complete message to the amp
                Log.i("String sent to amp", sbOut.toString());
                outputStream.write(sbOut.toString().getBytes());
            } catch (IOException e) {
                Log.e("Exception", e.getMessage());
                e.printStackTrace();
            }
        }
    }

    // Splits one byte into two nibbles and sends
    private String rs232SendNibble(int i) {
        if (i < 0) {
            i += 256;
        }


        byte[] bytes = new byte[2];
        // Upper nibble first
        bytes[0] = (byte)(((i & 0xF0) >> 4) + 48);
        // then lower
        bytes[1] = (byte)((i & 0x0F) + 48);

        return new String(bytes);

        // Translation:
        // Byte of x = nibble character
        // 0   = 0,0
        // 64  = 4,0  0100 = 4,  4  + 48 = 52 : 4  - 0100 0000
        // 100 = 6,4  0110 = 6,  6  + 48 = 54 : 6  - 0110 0100
        // 128 = 8,0  1000 = 8,  8  + 48 = 56 : 8  - 1000 0000
        // 255 = ?,?  1111 = 15, 15 + 48 = 63 : ?  - 1111 1111

        // For the character values - 16 possible (adding to 48) gives:
        // 0,1,2,3,4,5,6,7,8,9,:,;,<,=,>,?
    }

The sendMessageToAmp method is called by any UI listener method with an instruction containing what to send. This uses android resource strings defined strings.xml. These messages are simple one character instructions which are sent to the amplifier (see page before to see how it handles these).

    <string name="msgGetStatus">G</string>
    <string name="msgPowerSend">P</string>
    <string name="msgMuteSend">Q</string>
    <string name="msgVolumeSend">V</string>
    <string name="msgInputSend">I</string>
    <string name="msgFrontBalanceSend">F</string>
    <string name="msgRearBalanceSend">R</string>
    <string name="msgRearAdjustSend">r</string>
    <string name="msgCenAdjustSend">C</string>
    <string name="msgSubAdjustSend">S</string>
    <string name="msgSurroundSend">M</string>
    <string name="msgExtSurroundSend">E</string>
    <string name="msgTriggerSend">T</string>

A StringBuilder is used to build the entire string of bytes to send to the amplifier. This will always contain a command byte (G,P,Q,V,I,F,R,r,C,S,M,E or T above) and will then be followed by one, two or no value bytes.

As mentioned on the previous page, because sending a byte that can range from 0 to 255 in value (all 256 possibilities), this can interfere with handling of commands because the value sent might be the ascii representation of any of the command bytes above, or it could represent the line break bytes (decimal values 10 or 4). To get around this, bytes that cover this entire range (such as volume levels), are sent as two separate bytes representing each half (or nibble) of the entire byte.

This gives a range of just 16 possibilities. The rs232SendNibble method handles this by taking a single byte and splitting it into the most significant four bits first, shifting them 4 bits of the right and adding 48, and then creating another byte by taking the least significant 4 bits and again adding 48.

Adding 48 is done so that the nibbles sent cover the ascii range of characters from 0 (decimal 48) to the ? symbol (decimal 63). These are all numbers and characters that are not used for commands and are not line breaks or unprintable values.

The two halves are added to an array of two bytes, and this is returned as a String.

Some variables in the Android application are signed - these are the volume adjustments which range from negative values to positive values. The amplifier needs to receive these as unsigned character bytes though. The first piece of code in rs232SendNibble will make the value unsigned by checking if the value is negative, and if so, the number 256 is added to it to make it a correct unsigned value.

Back on the sendMessageToAmp method, once it has constructed a full StringBuilder containing the command and values, it adds a final byte \n to the StringBuilder (this is the line feed, or decimal value 10) and it sends the String to the amp by writing directly to the outputStream variable.

Bluetooth - receiving data

Receiving data is different. It may not be initiated by a user interacting with the UI and could be caused by a change on the amplifier itself, such as someone using the IR control instead.

When this happens, the app needs to be updated with the change in state of the amplifier itself. So that is can happen a Thread to listen for received data runs continuously whilst the app is active, and the Bluetooth is connected.

The listener runs in its own Thread itself, which is started only once the Bluetooth connection is initially complete (via the btConnectionHandler method). Since the Thread runs independently from the UI, Message and Handlers are again used so that the listener Thread can tell the main UI that data was received, and something needs to change.

Below is the code for that Thread, as well as the btReceivedUpdateHandler and associated rs232ReceiveNibble method.

    // This is the listener method, running a thread to listen for incoming 
        status info from the Amp
    private void beginListenForData() {

        new Thread() {
            @Override
            public void run() {

                // Endless until requested to stop
                while (!doStopListenThread) {
                    try {
                        // Get the input stream
                        BufferedReader brInputStream = new BufferedReader(new InputStreamReader(inputStream));
                        final String sBTReceived = brInputStream.readLine();
                        if (sBTReceived != null) {

                            Log.d(TAG, "RECD: " + sBTReceived);

                            // Typical input string = P0V00Q0I0F00R00r00C00S00M1E1T1
                            // Valid stream must start with P
                            if (sBTReceived.startsWith("P")) {
                                // Valid stream must be 30 bytes long
                                if (sBTReceived.length() == 30) {
                                    // All OK - process input data
                                    // Start looping over the information - start from the 2nd character
                                    for (int iReceivedCounter = 1; iReceivedCounter < 30; iReceivedCounter++) {
                                        // Check the previous character
                                        int iIn;
                                        // Check the previous character in a switch
                                        switch (sBTReceived.charAt(iReceivedCounter - 1)) {
                                            case 'P':
                                                // Power status 1 or 0
                                                iIn = Character.getNumericValue(sBTReceived.charAt(iReceivedCounter));
                                                if ((iIn == 0) || (iIn == 1))
                                                    iPower = iIn;
                                                break;
                                            case 'Q':
                                                // Mute status 1 or 0
                                                iIn = Character.getNumericValue(sBTReceived.charAt(iReceivedCounter));
                                                if ((iIn == 0) || (iIn == 1))
                                                    iMute = iIn;
                                                break;
                                            case 'V':
                                                // Volume level comes from the two nibbles
                                                iVolLevel = rs232ReceiveNibble(sBTReceived.charAt(iReceivedCounter), sBTReceived.charAt(iReceivedCounter + 1), false);
                                                // Advance forward another character as this command was two bytes long
                                                iReceivedCounter++;
                                                break;
                                            case 'I':
                                                // Active input 1 to 5
                                                iIn = Character.getNumericValue(sBTReceived.charAt(iReceivedCounter));
                                                if ((iIn >= 0) && (iIn <= 5))
                                                    iActiveInput = iIn;
                                                break;
                                            case 'F':
                                                // Volume level comes from the two nibbles
                                                iFrontBalance = rs232ReceiveNibble(sBTReceived.charAt(iReceivedCounter), sBTReceived.charAt(iReceivedCounter + 1), true);
                                                // Advance forward another character as this command was two bytes long
                                                iReceivedCounter++;
                                                break;
                                            case 'R':
                                                // Volume level comes from the two nibbles
                                                iRearBalance = rs232ReceiveNibble(sBTReceived.charAt(iReceivedCounter), sBTReceived.charAt(iReceivedCounter + 1), true);
                                                // Advance forward another character as this command was two bytes long
                                                iReceivedCounter++;
                                                break;
                                            case 'r':
                                                // Volume level comes from the two nibbles
                                                iRearAdjust = rs232ReceiveNibble(sBTReceived.charAt(iReceivedCounter), sBTReceived.charAt(iReceivedCounter + 1), true);
                                                // Advance forward another character as this command was two bytes long
                                                iReceivedCounter++;
                                                break;
                                            case 'C':
                                                // Volume level comes from the two nibbles
                                                iCentreAdjust = rs232ReceiveNibble(sBTReceived.charAt(iReceivedCounter), sBTReceived.charAt(iReceivedCounter + 1), true);
                                                // Advance forward another character as this command was two bytes long
                                                iReceivedCounter++;
                                                break;
                                            case 'S':
                                                // Volume level comes from the two nibbles
                                                iSubAdjust = rs232ReceiveNibble(sBTReceived.charAt(iReceivedCounter), sBTReceived.charAt(iReceivedCounter + 1), true);
                                                // Advance forward another character as this command was two bytes long
                                                iReceivedCounter++;
                                                break;
                                            case 'M':
                                                // Surround mode 1 or 0
                                                iIn = Character.getNumericValue(sBTReceived.charAt(iReceivedCounter));
                                                if ((iIn == 0) || (iIn == 1))
                                                    iSurroundMode = iIn;
                                                break;
                                            case 'E':
                                                // External surround mode 1 or 0
                                                iIn = Character.getNumericValue(sBTReceived.charAt(iReceivedCounter));
                                                if ((iIn == 0) || (iIn == 1))
                                                    iExtSurroundMode = iIn;
                                                break;
                                            case 'T':
                                                // Trigger activation - 0, 1, 2 or 3
                                                iIn = Character.getNumericValue(sBTReceived.charAt(iReceivedCounter));
                                                if ((iIn >= 0) && (iIn <= 3))
                                                    iTrigger = iIn;
                                                break;
                                        }
                                    }

                                    // Send to handler to update the GUI
                                    Message msg = btReceivedUpdateHandler.obtainMessage();
                                    btReceivedUpdateHandler.sendMessage(msg);

                                }
                            }

                        }
                    } catch (IOException ex) {
                        doStopListenThread = true;
                    }
                }
            }
        }.start();
    }

    // Handler based on updated variables received from Amp
    private final Handler btReceivedUpdateHandler = new Handler(new Handler.Callback() {

        @Override
        public boolean handleMessage(Message msg) {
            // Update the app display
            togglePower.setChecked(iPower == 1);
            setUiEnabled(isConnected);
            updateLevelDisplay(false);
            setRadioInput();
            toggleMute.setChecked(iMute == 1);
            check51Mode.setChecked(iExtSurroundMode == 1);
            checkHafler.setChecked(iSurroundMode == 1);
            checkTrigger1.setChecked((iTrigger & 1) > 0);
            checkTrigger2.setChecked((iTrigger & 2) > 0);
            updateFrontBalance(false);
            updateRearBalance(false);
            updateRearAdjust(false);
            updateCentreAdjust(false);
            updateSubAdjust(false);
            return false;
        }
    });

    // From two nibbles, returns the complete byte
    private int rs232ReceiveNibble(char su, char sl, boolean isSigned) {
        // Upper nibble first
        int cu = (su - 48) << 4;
        // then lower
        int cl = (sl - 48) & 0x0F;

        int co = cu + cl;
        if (isSigned && (co >= 128)) {
            co -= 256;
        }

        return co;
    }

This listen thread works by having an endless while loop that stops only when doStopListenThread is true. It creates a BufferedReader over the inputStream and will read a line from it. The readLine method will read an entire line, which means the amplifier must have sent bytes followed by the line feed byte (decimal 10).

I've designed the amplifier to send all its values at once. This means the command can be validated to ensure it is a length of 30 characters before processing. The following for loop will then process every command in the received bytes.

A switch statement will look for the current increment value (iReceivedCounter), minus 1 and check if it is either P,Q,V,I,F,R,r,C,S,M,E or T.

If it is any of these, the following byte or two is then processed. If the command is P,Q,I,M,E or T, only one following byte is processed, and the numeric value of the received byte is written to the relevant global variable in the Android application. In the Power example, this sets the global variable iPower, but only if the received value is either 0 or 1.

If the command is V,F,R,r,C or S - then the byte that follows could be anything from 0 to 255 and is therefore received in two halves (nibbles). The method rs232ReceiveNibble handles this by passing in the two separate received bytes, taking 48 from them, shifting the first received byte to the most significant 4 bits and then adding the two separate 4-bit values together.

Commands F,R,r,C or S are volume adjustment bytes, so in the application, they can be negative. To make sure the result is returned in a 32-bit signed int containing value of -127 to 128), if the result is higher than 128, then 256 is taken away from it.

Display and handling methods

To handle both user changes from controls and sending the result to the amplifier, as well as updating controls based on commands received from the amplifier, the following methods run on the main UI program.

Updating the volume is done with updateLevelDisplay. This has a parameter stating whether the update is from the user or not.

    // Update the volume level and show dB value
    private void updateLevelDisplay(boolean fromUser) {
        // If received from the user (user moved slider), get the volume level from the slider, or set it instead
        if (fromUser) {
            // only allow changes by 10 up or down
            if ((seekBar.getProgress() > (iVolLevel+10)) || (seekBar.getProgress() < (iVolLevel-10))) {
                seekBar.setProgress(iVolLevel);
            } else {
                iVolLevel = seekBar.getProgress(); // Seek bar contains the new volume
            }
        } else
            seekBar.setProgress(iVolLevel); // Seek bar needs setting to the current volume

        // Calculate dB level
        // Gain is 0dB
        float fLevel = 0;
        if (iVolLevel != 192) {
            fLevel = 31 - ((254-(float)iVolLevel) / 2);
        }
        NumberFormat formatter = new DecimalFormat("+#0.0;-#0.0");
        String sVolMessage = formatter.format(fLevel) + " dB";
        txtVol.setText(sVolMessage);

        // If connected and request was from user, send this to the amp
        if (isConnected && fromUser) {
            sendMessageToAmp(R.string.msgVolumeSend);
        }
    }

If the update is from the user, then a drag operation happened on the volume slider (SeekBar) and the result will be sent to the amplifier.

To prevent accidental sliding which could cause huge jumps in volume, a check is made to see if the new value is within 5dB (10 increments) of the previous value. If it isn't, the new value is ignored and the slider resets to the original position. If it is within the tolerance, global variable iVolLevel is set of the seek bar value.

Receiving data is different. It may not be initiated by a user interacting with the UI and could be caused by a change on the amplifier itself, such as someone using the IR control instead.

If the update is not from the user, then the volume slider is just set to the value of iVolLevel (which would have been set earlier by the Bluetooth receive methods).

The text box on the application will then display the value of iVolLevel in decibels dB. A float variable fLevel is calculated by taking 254 from the current volume, dividing that by 2 and taking the result away from 31. This gives the range of -95.5dB to 31.5dB in 0.5dB steps from a range of 0 to 255. With this calculation, the result value will be the same level displayed on the amplifier's LED character display.

For balance, the updateFrontBalance and updateRearBalance are similar to above. The SeekBar sliders are constrained to a range of 0 to 63 (31 being the centre). This gives plenty of range for balance and rear/centre/sub speaker adjustments in practice.

    // Update the front balance level and show dB value
    private void updateFrontBalance(boolean fromUser) {
        // For balance - positive means right balance - reduce left channel, negative means reduce right channel
        // If received from the user (user moved slider), get the volume level from the slider, or set it instead
        if (fromUser)
            iFrontBalance = seekBarFrontBal.getProgress() - 31; // Seek bar contains the new volume
        else
            seekBarFrontBal.setProgress(iFrontBalance + 31); // Seek bar needs setting to the current volume

        // Calculate dB level
        // Gain is 0dB
        float fLevel = 0;
        NumberFormat formatter;
        if (iFrontBalance != 0) {
            fLevel = (((float)iFrontBalance) / 2);
            formatter = new DecimalFormat("R+#0.0;L+#0.0");
        } else {
            formatter = new DecimalFormat("#0.0");
        }
        String sVolMessage = formatter.format(fLevel) + " dB";
        txtFrontBal.setText(sVolMessage);

        // If connected and request was from user, send this to the amp
        if (isConnected && fromUser) {
            sendMessageToAmp(R.string.msgFrontBalanceSend);
        }
    }

The calculation for the adjustment in dB displays L/R depending on the direction of the slider.

For level adjustments (rear, centre and subwoofer), again it is similar.

    // Update the Rear adjust level and show dB value
    private void updateRearAdjust(boolean fromUser) {
        // If received from the user (user moved slider), get the volume level from the slider, or set it instead
        if (fromUser)
            iRearAdjust = seekBarRearAdj.getProgress() - 31; // Seek bar contains the new volume
        else
            seekBarRearAdj.setProgress(iRearAdjust + 31); // Seek bar needs setting to the current volume

        // Calculate dB level
        // Gain is 0dB
        float fLevel = 0;
        NumberFormat formatter;
        if (iRearAdjust != 0) {
            fLevel = (((float)iRearAdjust) / 2);
            formatter = new DecimalFormat("+#0.0;-#0.0");
        } else {
            formatter = new DecimalFormat("#0.0");
        }
        String sVolMessage = formatter.format(fLevel) + " dB";
        txtRearAdj.setText(sVolMessage);

        // If connected and request was from user, send this to the amp
        if (isConnected && fromUser) {
            sendMessageToAmp(R.string.msgRearAdjustSend);
        }
    }

Level adjustment is displayed in dB with +/- in front depending on if the value is positive or negative.

For the input selection, this method is just an update from the amplifier rather than the user. The sending of a new value to the amplifier is done in the control handler itself.

// Set the current radio button from the current active input
    private void setRadioInput() {
        TextView txtInputSelected = (TextView)findViewById(R.id.textView4);
        RadioGroup radiogroup = (RadioGroup)findViewById(R.id.radioGroup1);

        if ((txtInputSelected != null) && (radiogroup != null)) {
            // Get the nth child from the input number
            RadioButton btn = (RadioButton) radiogroup.getChildAt(iActiveInput);
            // Check this button (others get deselected)
            radiogroup.check(btn.getId());
            // Set the current input text box to the text of this button
            txtInputSelected.setText(btn.getText());
        }
    }

It works by getting the child radio button in the group that matches the active input. Active input is from 0 to 3 and the order of the radio buttons in the group follow the same order as the relays attached to the preamplifier board! The text box is then set to the same text of the active radio button, so it is visually easy to identify in the app which input is active.

The final handler is for power on and off.

Toggling the power is a long operation. These methods send the command to the amplifier and whilst the amplifier program initiates that sequence, the application will display a progress dialog, preventing any other operations, for 9 seconds.

    // Power change method - changes the power state with a wait dialog
    public void doPowerChange() {
        if (iPower == 1) {
            // Switch off by sending new power
            iPower = 0;
            sendMessageToAmp(R.string.msgPowerSend);
            // Show powering off please wait... dialog
            pDialog = ProgressDialog.show(MainActivity.this, "Switching off", "Powering off, please wait...", true);
        } else {
            // Switch on by sending new power
            iPower = 1;
            sendMessageToAmp(R.string.msgPowerSend);
            // Show powering on please wait... dialog
            pDialog = ProgressDialog.show(MainActivity.this, "Switching on", "Powering on, please wait...", true);
        }
        // Wait for power to change - 9 seconds
        new Thread() {
            @Override
            public void run() {
                try {
                    Thread.sleep(9000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                // After waiting 9 seconds, send to handler to dismiss the please wait... message
                Message msg = powerComplete.obtainMessage();
                powerComplete.sendMessage(msg);
            }
        }.start();
    }

    // Handler for power change complete
    private final Handler powerComplete = new Handler(new Handler.Callback() {
        @Override
        public boolean handleMessage(Message msg) {
            // Power sequence completes, dismiss the please wait... message
            if (pDialog != null)
                pDialog.dismiss();
            setUiEnabled(isConnected);
            return false;
        }
    });

The dialog is displayed and closed by using a Thread which sleeps for 9 seconds, and then sends a Message which is picked up by the handler powerComplete, which then closes the wait dialog and enables / disables the UI controls.

App Conclusion

Update: As of July 2022, I've now retired Bluetooth RS232 in my amp to reduce idle power consumption, therefore I no longer use this app.

Still, I'm pleased with the result, miss using it (even though it was infrequently used), and it might be useful info for someone. When I did use it, control of my amplifier is convenient and reliable, with ease of use by a simple IR remote control, but where further settings are needed, configuring using an Android app is pretty slick and far easier to use than any dedicated remote control I've ever used (with about 100 button on it).

The other benefit is I learned a bit about Smart-phone app development along the way, which may prove useful in future. Although it was somewhat time consuming to get to the result, since this was a new learning path for me, the result is only just over 1000 lines of Java code in total. This is a very manageable amount of code, without splitting off into separate classes and packages.

I used to use it on occasions. Firstly, it has the more advanced controls. In day-to-day use, if the remote is near me then it's quicker to pick that up and adjust the volume. If it isn't though, it is quite quick to switch on the phones' Bluetooth, open the app, connect and change the volume. Partly though it's also remembering that I have it too!

Now though, I have a function mode built into the IR remote (by pressing and holding the mute button), which allows fairly easy adjustment of speaker levels, balance, triggers and surround modes.

References and more reading:
Source code on github.com
Android Expandable Layout Control Google Developers - Android Developer Guides
Google Developers - Android Studio
All About Circuits - Control an Arduino with Bluetooth
Code Is All - Android Expandable Layout