LoginSignup
9
10

【Android】PCとBluetooth接続し、PCに文字列を送信する

Last updated at Posted at 2022-08-12

はじめに

AndroidスマホをPCとBluetooth接続し、PCに文字列を送信するアプリをBluetooth接続のサンプルコード「android-BluetoothChat」を基に作成しました。「Android開発日記 AndroidのBluetooth通信(SPP)【初心者向け】」などにも同様の記事がありますが、将来の使い勝手を考慮し、接続先の選択をandroid-BluetoothChatと同様に別アクティビティでペア設定済みのデバイスから選択できたり、接続処理はUIスレッドとは別のスレッドでの処理としています。さらに、Android 12で動作させるために必要な変更を追加しています。

検証環境

この記事の内容は、以下の環境で検証した。

  • Android Studio Chipmunk | 2021.2.1 Patch 1
  • targetSdk 32
  • 実機確認 Sony SOG04 Android 12,API 31

MainActivity.java

Android Studio の Empty Activity を新規プロジェクトで起こし、必要部分をコピペしていきたいと思います。

まず、android-BluetoothChatMainActivity では bluetooth接続以降、Fragment で処理しているので、これを踏襲します。

つぎに、Bluetooth機器の操作にはRuntime Permissionが必要です。必要になってから許可しますか?と聞かれるより、アプリ開始直後にもらった方がいいような気がするので、onCreateから呼ばれるように追加しておきます。「アプリの権限をリクエストする」などを参考にして追加しました。

MainActivity.java
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import androidx.fragment.app.FragmentTransaction;

import android.Manifest;
import android.content.Context;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;

public class MainActivity extends AppCompatActivity {
    /**
     * Tag for Log
     */
    private static final String TAG = "MainActivity";

    private static final int REQUEST_PERMISSIONS_REQUEST_CODE = 0;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        if (savedInstanceState == null) {
            FragmentTransaction transaction = getSupportFragmentManager().beginTransaction();
            BluetoothChatFragment fragment = new BluetoothChatFragment();
            transaction.replace(R.id.main_layout, fragment);
            transaction.commit();
        }
        // Check if the user revoked runtime permissions.
        if (!checkPermissions()) {
            requestPermissions();
        }
    }
    /**
     * Return the current state of the permissions needed.
     */
    private boolean checkPermissions() {
        int permissionState = 0;
        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
            permissionState = ActivityCompat.checkSelfPermission(this,
                    Manifest.permission.BLUETOOTH_CONNECT);
        }
        Log.i(TAG, "android.os.Build.VERSION.SDK_INT = " + android.os.Build.VERSION.SDK_INT + ",checkPermissions = " + permissionState);    //  PERMISSION_GRANTED = 0
        return permissionState == PackageManager.PERMISSION_GRANTED;
    }

    private void requestPermissions() {
        boolean shouldProvideRationale = false;
        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
            shouldProvideRationale = ActivityCompat.shouldShowRequestPermissionRationale(this,
                    Manifest.permission.BLUETOOTH_CONNECT);
        }

        // Provide an additional rationale to the user. This would happen if the user denied the
        // request previously, but didn't check the "Don't ask again" checkbox.
        if (shouldProvideRationale) {   // 今後は確認しない をチェックしているしているか判断する
            //  false ならば表示する必要が無いと判断でき、かつ「今後は確認しない」状態であると判断することができる
        } else {
            Log.i(TAG, "Requesting permission");
            // Request permission. It's possible this can be auto answered if device policy
            // sets the permission in a given state or the user denied the permission
            // previously and checked "Never ask again".
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
                ActivityCompat.requestPermissions(this,
                        new String[]{Manifest.permission.BLUETOOTH_CONNECT},
                        REQUEST_PERMISSIONS_REQUEST_CODE);
            }
        }
    }
}

レイアウト設定の方は、必要最小限にしておきます。

activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity"
    android:id ="@+id/main_layout"
    >

</androidx.constraintlayout.widget.ConstraintLayout>

BluetoothChatFragment.java

Fragmentの追加

MainActivity から呼ばれるFragment を追加します。

MainActivityと同じ階層にBluetoothChatFragment.javaを新規で作りますが、BluetoothChatBluetoothChatFragmentは使わない部分もありそうなので、まずは onCreateonStartonCreateViewをコピー。

BluetoothChatFragment.java
import android.bluetooth.BluetoothAdapter;
import android.content.Intent;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Toast;

import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;

public class BluetoothChatFragment  extends Fragment {
    // Intent request codes
    private static final int REQUEST_ENABLE_BT = 3;
    /**
     * Local Bluetooth adapter
     */
    private BluetoothAdapter mBluetoothAdapter = null;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setHasOptionsMenu(true);
        // Get local Bluetooth adapter
        mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter();

        // If the adapter is null, then Bluetooth is not supported
        FragmentActivity activity = getActivity();
        if (mBluetoothAdapter == null && activity != null) {
            Toast.makeText(activity, "Bluetooth is not available", Toast.LENGTH_LONG).show();
            activity.finish();
        }
    }

    @Override
    public void onStart() {
        super.onStart();
        if (mBluetoothAdapter == null) {
            return;
        }
        // If BT is not on, request that it be enabled.
        // setupChat() will then be called during onActivityResult
        if (!mBluetoothAdapter.isEnabled()) {
            Intent enableIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE);
            startActivityForResult(enableIntent, REQUEST_ENABLE_BT);
        }
    }

    @Override
    public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container,
                             @Nullable Bundle savedInstanceState) {
        return inflater.inflate(R.layout.fragment_bluetooth_chat, container, false);
    }
}

非推奨のstartActivityForResult()に対応

あれっ、フラグメントのstartActivityForResult() が非推奨になってる!ということで調べると「アクティビティの結果を取得する」という記事があるのでこれを参考にActivityResultLauncher<Intent>を使った方法を試します。result.getResultCode()で取り出した結果がActivity.RESULT_OKでなければ、メッセージをトーストで出して終了、そうでなければ次のユーザー操作なり、イベントを待つことになります。

BluetoothChatFragment.java
    @Override
    public void onStart() {
        super.onStart();
        if (mBluetoothAdapter == null) {
            return;
        }
        // If BT is not on, request that it be enabled.
        // setupChat() will then be called during onActivityResult
        if (!mBluetoothAdapter.isEnabled()) {
            Intent enableIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE);
//            startActivityForResult(enableIntent, REQUEST_ENABLE_BT);
            mStartBluetoothAdapterEnable.launch(enableIntent);
        }
    }

    ActivityResultLauncher<Intent> mStartBluetoothAdapterEnable = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(),
            result -> {
                if (result.getResultCode() != Activity.RESULT_OK) {
                    // User did not enable Bluetooth or an error occurred
                    FragmentActivity activity = getActivity();
                    if (activity != null) {
                        Toast.makeText(activity, R.string.bt_not_enabled_leaving,
                                Toast.LENGTH_SHORT).show();
                        activity.finish();
                    }
                }
            });

R.string.bt_not_enabled_leavingres/values/strings.xmlandroid-BluetoothChatからコピー。これは、スマホの「設定」のBluetoothがOFFのまま、アプリを起動した際に、”「(アプリ名)」がBluetoothを ON にしようとしています”と表示され、許可されない時にトーストで出るメッセージです。

strings.xml
    <string name="bt_not_enabled_leaving">Bluetooth was not enabled. Leaving Bluetooth Chat.</string>

許可されるとスマホの「設定」にあるBluetoothがONになります。

接続先の指定

使用するAndroid端末とBluetooth端末(今回はPC)をペア設定する部分はAndroid端末の「設定」にある機能を利用し、アプリではペア設定済みの端末のリストから選択することにします。つまり、アプリ内で独自にBluetooth端末をスキャンしたり、ペア設定する機能は含まないことにします。従って、今回のアプリの接続実験をする際は、接続したいBluetooth端末とは事前にペア設定しておくことをお願いします。

アプリ内での接続先の選択は、BluetoothChatを踏襲し、画面のオプションメニューから選択できるようにします。
そのためには、BluetoothChatFragmentに、onCreateOptionsMenuonOptionsItemSelectedを追加します。

BluetoothChatFragment.java
    @Override
    public void onCreateOptionsMenu(@NonNull Menu menu, MenuInflater inflater) {
        inflater.inflate(R.menu.bluetooth_menu, menu);
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        switch (item.getItemId()) {
            case R.id.connect: {
                Log.d("onOptionsItemSelected", "push R.id.connect");
                // Launch the DeviceListActivity to see devices and do scan
                Intent serverIntent = new Intent(getActivity(), DeviceListActivity.class);
//                startActivityForResult(serverIntent, REQUEST_CONNECT_DEVICE_SECURE);
                mGetBluetoothAddress.launch(serverIntent);
                return true;
            }
        }
        return false;
    }

    ActivityResultLauncher<Intent> mGetBluetoothAddress = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(),
        result -> {
            if (result.getResultCode() == Activity.RESULT_OK) {
                Bundle extras;
                if (result.getData() != null) {
                    extras = result.getData().getExtras();
                    address = extras.getString(DeviceListActivity.EXTRA_DEVICE_ADDRESS);
                    Log.d("mGetBluetoothAddress.getData ", "address = " + address);
                }
            }
        });

オプションメニューを開いて、押してほしいメニュー項目の名前をstrings.xmlに追加しておきます。

strings.xml
    <!-- Options Menu -->
    <string name="connect">Connect a device</string>

これで、オプションメニューから「Connect a device」と書かれたメニュー項目を選択すると、DeviceListActivityが呼ばれて接続先アドレスをユーザーに選択してもらい、その結果が戻ってくるので、String addressで受け取るようにしておきます。

OptionMenu
Connect a device

ただし、ここもstartActivityForResultは”非推奨”なので、ActivityResultLauncher<Intent> mGetBluetoothAddress
というメソッドを作りました。これをstartActivityForResultの代わりにlaunchします。

また、R.menu.bluetooth_menu は、res ディレクトリに menu ディレクトリを作り、bluetooth_menu.xml を新規に作って、BluetoothChatres/menu/bluetooth_chat.xmlから必要な部分をコピーします。

bluetooth_menu.xml
<?xml version="1.0" encoding="utf-8"?>

<menu xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:android="http://schemas.android.com/apk/res/android">

    <item
        android:id="@+id/connect"
        android:title="@string/connect"
        app:showAsAction="never" />

</menu>

secure かどうか、の部分は後で述べますが、使わないようにしましたので、選択肢は一つになりました。

接続の開始と終了

元々のBlueToothChatは、画面上でテキストを入力して、send ボタンを押すと送信されるようなユーザーインターフェースです。今回のアプリは「START」ボタンを押すと接続を開始し、定型の文字列を送信する、ということをやりたいので、自由にテキストを入力する部分は、なしにします。また、将来的にはそこそこ長時間、つなぎっ放しにしたいので、バックグラウンド(ForegroundService)で呼び出すようにstartForegroundService(intent)を使います。そうすると、停止するボタンを作っておかないと動きっぱなしになるので「STOP」ボタンで終了できるようにしておきます。その際はstopService(intent)を実行します。

また、うっかりOptionMenuから接続先を選ぶのを忘れて、いきなり「START」ボタンを押すと、adressがnullのままサービスが呼び出されてしまい接続時にエラーになるので、nullのときや値が入っていないときは呼ばないようにし、トーストを出すようにしておきました。

BluetoothChatFragment.java
public class BluetoothChatFragment extends Fragment {
	...
	
    private Button mButtonStart;
    private Button mButtonStop;

	...
	

    @Override
    public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
        mButtonStart = view.findViewById(R.id.button_start);
        mButtonStart.setOnClickListener( v -> {
            if(address == null || address.length() == 0){
                FragmentActivity activity = getActivity();
                if (activity != null) {
                    Toast.makeText(activity, R.string.no_btAddress,
                            Toast.LENGTH_SHORT).show();
                    Log.d("button_start", getString(R.string.no_btAddress));
                }
            } else {
                Intent intent = new Intent(getActivity(), MyService.class);
                intent.putExtra("REQUEST_CODE", 1);
                intent.putExtra("BT_address", address);

                // Serviceの開始
                //startService(intent);
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                    requireActivity().startForegroundService(intent);
                }
            }
        });
        mButtonStop = view.findViewById(R.id.button_stop);
        mButtonStop.setOnClickListener( v -> {
            Log.d("debug", "button_stop");
            Intent intent = new Intent(getActivity(), BluetoothChatService.class);
            // Serviceの停止
            requireActivity().stopService(intent);
        });
    }
}

BluetoothChatFragment のレイアウトも指定が必要なので、そこにボタンの定義も併せて入れておきます。fragment_bluetooth_chat.xmlを/res/layout ディレクトリに新規で作ります。

fragment_bluetooth_chat.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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">
    <Button
        android:id="@+id/button_start"
        android:text="@string/start"
        android:layout_margin="40dp"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toTopOf="@+id/button_stop"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        />

    <Button
        android:id="@+id/button_stop"
        android:text="@string/stop"
        android:layout_margin="40dp"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/button_start"
        />

</androidx.constraintlayout.widget.ConstraintLayout>

表示する文字もstrings.xmlも追加しておきます。

res/values/strings.xml
    <string name="start">Start</string>
    <string name="stop">Stop</string>
    <string name="no_btAddress">Bluetooth接続先が選択されていません</string>

画面イメージは以下です。

画面イメージ

BluetoothChatFragment のまとめ

元々のBluetoothChatBluetoothChatFragment.javaでBluetoothの使用が許可された場合、onActivityResult()にあるsetupChat()が実行されていますが、その中身はボタンを押したときの処理等の定義で、前述のように今回はバックグラウンド(ForegroundService)で使いたいので、この部分は使いませんでした。その他、statusの操作や、handlerはとりあえず不要なので使いません。

生き残った部分は、以下のようになりました。

BluetoothChatFragment.java
import android.app.Activity;
import android.bluetooth.BluetoothAdapter;
import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.Toast;

import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;

public class BluetoothChatFragment  extends Fragment {
    // Intent request codes
//    private static final int REQUEST_CONNECT_DEVICE_SECURE = 1;
//    private static final int REQUEST_ENABLE_BT = 3;
    /**
     * Local Bluetooth adapter
     */
    private BluetoothAdapter mBluetoothAdapter = null;

    private Button mButtonStart;
    private Button mButtonStop;

    String address;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setHasOptionsMenu(true);
        // Get local Bluetooth adapter
        mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter();

        // If the adapter is null, then Bluetooth is not supported
        FragmentActivity activity = getActivity();
        if (mBluetoothAdapter == null && activity != null) {
            Toast.makeText(activity, "Bluetooth is not available", Toast.LENGTH_LONG).show();
            activity.finish();
        }
    }

    @Override
    public void onStart() {
        super.onStart();
        if (mBluetoothAdapter == null) {
            return;
        }
        // If BT is not on, request that it be enabled.
        // setupChat() will then be called during onActivityResult
        if (!mBluetoothAdapter.isEnabled()) {
            Intent enableIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE);
//            startActivityForResult(enableIntent, REQUEST_ENABLE_BT);
            mStartBluetoothAdapterEnable.launch(enableIntent);
        }
    }

    @Override
    public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container,
                             @Nullable Bundle savedInstanceState) {
        return inflater.inflate(R.layout.fragment_bluetooth_chat, container, false);
    }

    ActivityResultLauncher<Intent> mStartBluetoothAdapterEnable = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(),
            result -> {
                if (result.getResultCode() != Activity.RESULT_OK) {
                    // User did not enable Bluetooth or an error occurred
                    FragmentActivity activity = getActivity();
                    if (activity != null) {
                        Toast.makeText(activity, R.string.bt_not_enabled_leaving,
                                Toast.LENGTH_SHORT).show();
                        activity.finish();
                    }
                }
            });

    @Override
    public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
        mButtonStart = view.findViewById(R.id.button_start);
        mButtonStart.setOnClickListener( v -> {
            if(address == null || address.length() == 0){
                FragmentActivity activity = getActivity();
                if (activity != null) {
                    Toast.makeText(activity, R.string.no_btAddress,
                            Toast.LENGTH_SHORT).show();
                    Log.d("button_start", getString(R.string.no_btAddress));
                }
            } else {
                Intent intent = new Intent(getActivity(), MyService.class);
                intent.putExtra("REQUEST_CODE", 1);
                intent.putExtra("BT_address", address);

                // Serviceの開始
                //startService(intent);
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                    requireActivity().startForegroundService(intent);
                }
            }
        });
    }

    @Override
    public void onCreateOptionsMenu(@NonNull Menu menu, MenuInflater inflater) {
        inflater.inflate(R.menu.bluetooth_menu, menu);
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        switch (item.getItemId()) {
            case R.id.connect: {
                Log.d("onOptionsItemSelected", "push R.id.connect");
                // Launch the DeviceListActivity to see devices and do scan
                Intent serverIntent = new Intent(getActivity(), DeviceListActivity.class);
//                startActivityForResult(serverIntent, REQUEST_CONNECT_DEVICE_SECURE);
                mGetBluetoothAddress.launch(serverIntent);
                return true;
            }
        }
        return false;
    }

    ActivityResultLauncher<Intent> mGetBluetoothAddress = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(),
            result -> {
                if (result.getResultCode() == Activity.RESULT_OK) {
                    Bundle extras;
                    if (result.getData() != null) {
                        extras = result.getData().getExtras();
                        address = extras.getString(DeviceListActivity.EXTRA_DEVICE_ADDRESS);
                        Log.d("mGetBluetoothAddress.getData ", "address = " + address);
                    }
                }
            });

}

DeviceListActivity.java

ペア設定済みの端末のリストから接続先を選択する部分はDeviceListActivity.javaです。
まだBluetoothChatからDeviceListActivity.javaをコピーしていなかったので、これもMainActivityと同列の場所にコピー。

DeviceListActivity.java

import android.Manifest;
import android.app.Activity;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.ListView;
import android.widget.TextView;

import androidx.core.app.ActivityCompat;

import java.util.Set;

/**
 * This Activity appears as a dialog. It lists any paired devices.
 * When a device is chosen by the user,
 * the MAC address of the device is sent back to the parent
 * Activity in the result Intent.
 */
public class DeviceListActivity extends Activity {

    /**
     * Tag for Log
     */
    private static final String TAG = "DeviceListActivity";

    /**
     * Return Intent extra
     */
    public static String EXTRA_DEVICE_ADDRESS = "device_address";

    /**
     * Member fields
     */
    private BluetoothAdapter mBtAdapter;

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

        Log.d(TAG, "onCreate");
        // Setup the window
//        requestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS);
        setContentView(R.layout.activity_device_list);

        // Set result CANCELED in case the user backs out
        setResult(Activity.RESULT_CANCELED);

        // Initialize array adapters. One for already paired devices and
        // one for newly discovered devices
        ArrayAdapter<String> pairedDevicesArrayAdapter =
                new ArrayAdapter<>(this, R.layout.device_name);

        // Find and set up the ListView for paired devices
        ListView pairedListView = findViewById(R.id.paired_devices);
        pairedListView.setAdapter(pairedDevicesArrayAdapter);
        pairedListView.setOnItemClickListener(mDeviceClickListener);

        // Get the local Bluetooth adapter
        mBtAdapter = BluetoothAdapter.getDefaultAdapter();

        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S
                && ActivityCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT)
                	 != PackageManager.PERMISSION_GRANTED) {
            // TODO: Consider calling
            //    ActivityCompat#requestPermissions
            // here to request the missing permissions, and then overriding
            //   public void onRequestPermissionsResult(int requestCode, String[] permissions,
            //                                          int[] grantResults)
            // to handle the case where the user grants the permission. See the documentation
            // for ActivityCompat#requestPermissions for more details.
            Log.d(TAG, "checkSelfPermission != PackageManager.PERMISSION_GRANTED");
            return;
        }
        // Get a set of currently paired devices
        Set<BluetoothDevice> pairedDevices = mBtAdapter.getBondedDevices();
        Log.d(TAG, "pairedDevices.size() = " + pairedDevices.size());

        // If there are paired devices, add each one to the ArrayAdapter
        if (pairedDevices.size() > 0) {
            findViewById(R.id.title_paired_devices).setVisibility(View.VISIBLE);
            for (BluetoothDevice device : pairedDevices) {
                pairedDevicesArrayAdapter.add(device.getName() + "\n" + device.getAddress());
                Log.d(TAG, "deviceName = " + device.getName() + ",Address = " + device.getAddress());
            }
        } else {
            String noDevices = getResources().getText(R.string.none_paired).toString();
            pairedDevicesArrayAdapter.add(noDevices);
        }
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
    }

    /**
     * The on-click listener for all devices in the ListViews
     */
    private AdapterView.OnItemClickListener mDeviceClickListener
            = new AdapterView.OnItemClickListener() {
        public void onItemClick(AdapterView<?> av, View v, int arg2, long arg3) {

            // Get the device MAC address, which is the last 17 chars in the View
            String info = ((TextView) v).getText().toString();
            String address = info.substring(info.length() - 17);

            // Create the result Intent and include the MAC address
            Intent intent = new Intent();
            intent.putExtra(EXTRA_DEVICE_ADDRESS, address);

            // Set result and finish this Activity
            setResult(Activity.RESULT_OK, intent);
            finish();
        }
    };
}

ただし、新規のデバイスを探したり、ペア設定する部分はアプリ内でやらないつもりなので、その部分は削除してあります。また、オリジナルでは、requestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS) となっているところが取り消し線表示になったのでカーソルを持っていくと

'FEATURE_INDETERMINATE_PROGRESS' is deprecated as of API 24: Android 7.0 (Nougat)

が出ました。ActionbarにprogressBarを表示するそうですが、とりあえずコメントアウトで対応しました。さらに、mBtAdapter.getBondedDevices()

Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`

と言われるので修正候補を選択すると、この行の前に

        if (ActivityCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
            // TODO: Consider calling
            //    ActivityCompat#requestPermissions
            // here to request the missing permissions, and then overriding
            //   public void onRequestPermissionsResult(int requestCode, String[] permissions,
            //                                          int[] grantResults)
            // to handle the case where the user grants the permission. See the documentation
            // for ActivityCompat#requestPermissions for more details.
            return;
        }

が挿入されました。とりあえず、ここはこれに従っています。

なお、BLUETOOTH_CONNECTの権限は今後徐々に減ってくるとは思ますが、Android 11以下では不要なので、もし実験したいスマホが古いものだった場合は接続したいデバイスがペア設定してあっても接続先リストが出てこない可能性があります。実際、筆者もちょっとあせりました。その場合は

        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S
            && ActivityCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
            // TODO: Consider calling
            //    ActivityCompat#requestPermissions
            // here to request the missing permissions, and then overriding
            //   public void onRequestPermissionsResult(int requestCode, String[] permissions,
            //                                          int[] grantResults)
            // to handle the case where the user grants the permission. See the documentation
            // for ActivityCompat#requestPermissions for more details.
            return;
        }

としておくとペア設定済みデバイスリストが表示されると思います。

最後にpairedListViewsetOnItemClickListenerで渡されているmDeviceClickListenerの設定があります。DeviceListActivityで画面に表示されたペア設定済みデバイスのリストの中から、今回接続したいデバイスを選択してもらい、Intentに入れて呼び出しに送り返しています。

res/layout/activity_device_list.xmlも”ペア設定済み”の部分だけBluetoothChatからコピー。

activity_device_list.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"
    >

    <TextView
        android:id="@+id/title_paired_devices"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="#666"
        android:paddingLeft="5dp"
        android:text="@string/title_paired_devices"
        android:textColor="#fff"
        android:visibility="gone"
        />

    <ListView
        android:id="@+id/paired_devices"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:stackFromBottom="true"
        />
</LinearLayout>

res/layout/device_name.xmlBluetoothChatからコピー。

device_name.xml
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
          android:layout_width="match_parent"
          android:layout_height="wrap_content"
          android:padding="5dp"
          android:textSize="18sp"
    />

strings.xmlに以下を追加

strings.xml
    <!--  DeviceListActivity -->
    <string name="select_device">select a device to connect</string>
    <string name="none_paired">No devices have been paired</string>
    <string name="title_paired_devices">Paired Devices</string>

後でもう一度出てきますが、新しいアクティビティとして、マニフェストに忘れず追加しておきます。

AndroidManifest.xml
        <activity
            android:name=".DeviceListActivity"
            android:configChanges="orientation|keyboardHidden"
            android:label="@string/select_device"
            android:theme="@android:style/Theme.Holo.Dialog"/>

接続先選択画面はこんな感じです。もちろん、ペア設定済みデバイスの数は同じではありませんので、ペア設定済みデバイスの数に応じてリストアップされるデバイスの数は変化します。

接続先選択画面.png

BluetoothConnection.java

概要

元々のBluetoothChatBluetoothChatService.javaの中身は、startconnect等のメソッドと、AcceptThreadConnectThreadConnectedThreadがあります。AcceptThreadは、「Bluetoothの概要」「サーバー側の接続」にも類似のものが出てきますが、今回はPC側からスマホを接続する構成は考えていませんので使わないことにします。
また、バックグラウンド(ForegroundService)を使う関係で、バックグラウンドで動かすのに必要な処理をまとめたMyServiceと、bluetooth接続処理を新しくBluetoothConnection.javaというクラスを作って分割し、MyServiceからBluetoothConnectionのメソッドを呼ぶ構成にしました。

ConnectThread

クライアント側として接続する部分を確認します。「Bluetoothの概要」の「クライアント側の接続」と見比べると、BluetoothChatBluetoothChatService.javaConnectThreadの方は、引数にboolean secureが追加されていて、BluetoothSocket mmSocketBluetoothDevice mmDeviceと関連付け、secure が true のときはMY_UUID_SECUREを選択、InsecureのときはMY_UUID_INSECUREを選択肢するところが追加になっています。

secure かどうかでUUIDを分ける?と探したところ、「Bluetooth通信の実装|Android開発」にあるように、 SPP (シリアルポートプロファイル) の UUID ("00001101-0000-1000-8000-00805f9b34fb")を使えばよさそうなので、使ってみます。ところが、実際にこのコードをコピーしてみると、mDevice.createRfcommSocketToServiceRecord(SERIAL_PORT_PROFILE)部分に赤い波線が出て、ここでも

	Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`

が出ますので、パーミッションチェックを入れます。

            try {
                if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S
                        && ActivityCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
                    return;
                }
                mSocket = mDevice.createRfcommSocketToServiceRecord(SERIAL_PORT_PROFILE);
            } catch (IOException e) {
                Log.w(TAG, "BluetoothConnection couldn't be established due to an exception: " + e);
                mSocket = null;
            }

mSocket.connect()も同様です。そうなると、Contextを持って来なければいけなくなるので、コンストラクタにcontextを引数に追加しておき、呼び出し側のサービスのContextを渡すようにしました。

    public BluetoothConnection(Context _context, String bluetoothAddress) {
        this(_context, BluetoothAdapter.getDefaultAdapter().getRemoteDevice(bluetoothAddress));
    }

    public BluetoothConnection(Context _context, BluetoothDevice device) {
        context = _context;

        mDevice = Objects.requireNonNull(device);
    }

ConnectThreadはメインスレッドではありませんので、接続処理をしても成功したのか外からは分かりませんので、接続成功/失敗を知らせてくれるリスナーも用意します。

    /** Bluetooth接続が確立・切断された通知を受けるクラスが実装するインタフェース */
    public interface connectionInterface{
        /** Bluetooth接続の確立通知を受けるメソッド */
        void onBluetoothConnected();
        /** Bluetooth接続失敗の通知を受けるメソッド */
        void onBluetoothConnectFailed();
    }

mSocket.connect()でエラーが出たら(catch文の方に処理が移行したら) onBluetoothConnectFailed() を、無事終了したらonBluetoothConnected()が呼ばれるようにしておきます。

BluetoothConnection.java のまとめ

以上の内容をひとつにまとめると、以下のようになりました。

BluetoothConnection.java
import android.Manifest;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothSocket;
import android.content.Context;
import android.content.pm.PackageManager;
import android.util.Log;

import androidx.core.app.ActivityCompat;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Objects;
import java.util.UUID;

public class BluetoothConnection {
    // Debugging
    private static final String TAG = "BluetoothConnection";

    Context context;

    /**
     * This is the well-known UUID for the Bluetooth SPP (Serial Port Profile)
     */
    private static final UUID SERIAL_PORT_PROFILE = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB");

    private final BluetoothDevice mDevice;
    private BluetoothSocket mSocket = null;

    // Member fields
    private ConnectThread mConnectThread;

    /** Bluetooth接続が確立・切断された通知を受けるクラスが実装するインタフェース */
    public interface connectionInterface{
        /** Bluetooth接続の確立通知を受けるメソッド */
        void onBluetoothConnected();
        /** Bluetooth接続失敗の通知を受けるメソッド */
        void onBluetoothConnectFailed();
    }

    private connectionInterface connectionListener = null;
    /** リスナを設定するメソッド */
    public void setConnectionListener(connectionInterface listener) {
        connectionListener = listener;
    }

    public BluetoothConnection(Context _context, String bluetoothAddress) {
        this(_context, BluetoothAdapter.getDefaultAdapter().getRemoteDevice(bluetoothAddress));
    }

    public BluetoothConnection(Context _context, BluetoothDevice device) {
        context = _context;

        mDevice = Objects.requireNonNull(device);
    }

//    @Override
    public String getAddress() {
        return mDevice.getAddress();
    }

    /**
     * Establish an RFCOMM connection to the remote device.
     *
     * Assumes there is no existing connection.
     *
     * This method may take time to return (or even not return in pathological cases).
     * It is a good idea to wrap it in some kind of Promise-like object.
     *
     * @return true if it could connect, false otherwise
     */
    public void connect() {
        // Start the thread to connect with the given device
        mConnectThread = new ConnectThread();
        mConnectThread.start();
    }

//    @Override
    public boolean isConnected() {
        return mSocket != null && mSocket.isConnected();
    }

    public synchronized void close() {

        if (mConnectThread != null) {
            mConnectThread.cancel();
            mConnectThread = null;
        }

        if (isConnected()) {
            try {
                mSocket.close();
                Log.d(TAG, "mSocket.close");
            } catch (IOException e) {
                // we are letting go of the connection anyway, so log and continue
                Log.w(TAG, "IOException during BluetoothSocket close(): " + e);
            } finally {
                mSocket = null;
            }
        } else {
            Log.d(TAG, "is NOT Connected");
        }
    }

//    @Override
    public InputStream getInputStream() {
        if (isConnected()) {
            try {
                return mSocket.getInputStream();
            } catch (IOException e) {
                Log.w(TAG, "failed to get Bluetooth input stream: " + e);
            }
        }
        return null;
    }

//    @Override
    public OutputStream getOutputStream() {
        if (isConnected()) {
            try {
                return mSocket.getOutputStream();
            } catch (IOException e) {
                Log.w(TAG, "failed to get Bluetooth output stream: " + e);
            }
        }
        return null;
    }

    /**
     * This thread runs while attempting to make an outgoing connection
     * with a device. It runs straight through; the connection either
     * succeeds or fails.
     */
    private class ConnectThread extends Thread {

        public ConnectThread() {

            // Get a BluetoothSocket for a connection with the
            // given BluetoothDevice
            try {
                if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S
                        && ActivityCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
                    return;
                }
                mSocket = mDevice.createRfcommSocketToServiceRecord(SERIAL_PORT_PROFILE);
            } catch (IOException e) {
                Log.w(TAG, "BluetoothConnection couldn't be established due to an exception: " + e);
                mSocket = null;
            }
        }

        public void run() {
            Log.d("BEGIN ConnectThread", "Thread name = " + Thread.currentThread().getName());


            // Make a connection to the BluetoothSocket

            try {
                // This is a blocking call and will only return on a
                // successful connection or an exception
                if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S
                        && ActivityCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
                    return;
                }
                mSocket.connect();
            } catch (IOException e) {
                // Close the socket
                try {
                    mSocket.close();
                } catch (IOException e2) {
                    Log.e(TAG, "unable to close() " +
                            " socket during connection failure", e2);
                }
                Log.d(TAG, "connectionFailed", e);
                if(connectionListener != null){
                    connectionListener.onBluetoothConnectFailed();
                }
                return;
            }

            // Reset the ConnectThread because we're done
            synchronized (BluetoothConnection.this) {
                Log.d(TAG, "mConnectThread = null");
                mConnectThread = null;
            }

            Log.d(TAG, "Connected !");
            if(connectionListener != null){
                connectionListener.onBluetoothConnected();
            }
        }

        public void cancel() {
            try {
                Log.d(TAG, "mSocket.close()");
                mSocket.close();
            } catch (IOException e) {
                Log.e(TAG, "socket failed", e);
            }
        }
    }
}

MyService.java

MyService.javaでは、バックグラウンド(ForegroundService)開始のための処理と、サービスを開始したらBluetoothConnectionのインスタンスを作成し、接続するconnectメソッドを呼び出すメソッドconnectDevice(String address)、接続処理が完了したら文字列を送信するスレッドConnectedThreadを用意しました。

サービスを作成するには、onStartCommand()が呼び出されますが、のPendingIntent.getActivity(Context, int, Intent, int)の最後のintは、int flagsで、今回はPendingIntent.FLAG_IMMUTABLEにしています。以前のサンプルだとPendingIntent.FLAG_UPDATE_CURRENTになっているものがありますが、実行時にエラーが出て、Android Studioに

Strongly consider using FLAG_IMMUTABLE

と怒られたのでそうしてあります。「ほとんどケースで FLAG_IMMUTABLE を指定することになると思います」だそうです。

さらに、アプリ画面上の「STOP」ボタンを押すと、MyServiceonDestroy() が呼ばれるので、ループ処理フラグをOFF(onActive = false)、for ループ breakとなる処理も追加しておきます。

MyService.java
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.os.IBinder;
import android.util.Log;

import java.io.IOException;
import java.io.OutputStream;
import java.util.Objects;

public class MyService extends Service implements BluetoothConnection.connectionInterface {

    private BluetoothConnection mBluetoothConnection;

    ConnectedThread thread;

    public IBinder onBind(Intent intent) {
        return null;
    }

    public int onStartCommand(Intent intent, int flags, int startId) {
        Log.d("MyService", "onStartCommand()");
        Log.d("MyService", "Thread name = " + Thread.currentThread().getName());

        int requestCode = intent.getIntExtra("REQUEST_CODE",0);
        Context context = getApplicationContext();
        String channelId = "default";
        String title = context.getString(R.string.app_name);

        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {

            PendingIntent pendingIntent =
                    PendingIntent.getActivity(context, requestCode, intent, PendingIntent.FLAG_IMMUTABLE);

            NotificationManager notificationManager =
                    (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);

            // Notification Channel 設定
            NotificationChannel channel = new NotificationChannel(
                    channelId, title, NotificationManager.IMPORTANCE_DEFAULT);

            if (notificationManager != null) {
                notificationManager.createNotificationChannel(channel);

                Notification notification = new Notification.Builder(context, channelId)
                        .setContentTitle(title)
                        // android標準アイコンから
                        .setSmallIcon(android.R.drawable.ic_media_play)
                        .setContentText("MediaPlay")
                        .setAutoCancel(true)
                        .setContentIntent(pendingIntent)
                        .setWhen(System.currentTimeMillis())
                        .build();

                // startForeground
                startForeground(1, notification);

            }
        }

        String address = intent.getStringExtra("BT_address");

        if(mBluetoothConnection == null){
            connectDevice(address);
        } else {
            mBluetoothConnection.close();
            mBluetoothConnection = null;
            connectDevice(address);
        }

        return START_NOT_STICKY;
    }
    @Override
    public void onDestroy(){
        thread.cancel();
    }

    private void connectDevice(String address){
        // Get the BluetoothDevice object
        Log.d("connectDevice ", "address = " + address);
        mBluetoothConnection = new BluetoothConnection(this, address);
        mBluetoothConnection.setConnectionListener(this);
        mBluetoothConnection.connect();
    }

    @Override
    public void onBluetoothConnected() {
        thread = new ConnectedThread();
        thread.start();
    }

    @Override
    public void onBluetoothConnectFailed() {
        Log.d(getClass().getSimpleName(), "onBluetoothConnectFailed");
    }

    private class ConnectedThread extends Thread {
        boolean onActive = true;
        OutputStream out = Objects.requireNonNull(mBluetoothConnection.getOutputStream());

        public void run() {
            Log.d("ConnectedThread run", "Thread name = " + Thread.currentThread().getName());
            try {
                out.write(("Hello!" + "\r").getBytes());
                out.flush();
                synchronized (this) {
                    for (int i = 0; i < 10; i++) {
                        out.write((" i = " + i+ "\r").getBytes());
                        try {
                            wait(1000);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                        if(!onActive) break;
                    }
                }
            } catch (IOException e) {
                e.printStackTrace();
            }

            Log.d("ConnectedThread", "end");
        }
        public void cancel(){
            onActive = false;
            Log.d("ConnectedThread", "cancel");
        }
    }
}

AndroidManifest

manifestへのパーミッションの追加

まず、Bluetoothを使う場合はBluetooth の概要にある通り、BLUETOOTHACCESS_FINE_LOCATIONのパーミッションが必要だそうで、manifestに追加します。

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    package="com.example.myapplication">

  <uses-permission android:name="android.permission.BLUETOOTH" />
  <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />

  <!-- If your app targets Android 9 or lower, you can declare
       ACCESS_COARSE_LOCATION instead. -->
  <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
  ...

すると、Android Studioに

If you need access to FINE location, you must request both `ACCESS_FINE_LOCATION` and `ACCESS_COARSE_LOCATION`

と怒られるのでそれを追加。Android 12(API レベル 31)からBluetooth接続するためにBLUETOOTH_CONNECTも必要になるのでこれも追加します。さらに、bluetooth接続をバックグラウンド(ForegroundService)で実施したいので、Android 9(API レベル 28)以降ではFOREGROUND_SERVICEを追記する必要があるでこれも追加します。

Activity、service

manifestには、Empty Activityに含まれるMainActivityが既に記述されていますので、これまでで出てきた DeviceListActivity を追加しておきます。さらに、serviceも記載しておかないと起動できないので、忘れず追加します。

AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    package="com.example.myapplication">

    <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
    <uses-permission android:name="android.permission.BLUETOOTH" />
    <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />

    <!-- If your app targets Android 9 or lower, you can declare
         ACCESS_COARSE_LOCATION instead. -->
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />

    <!-- API 28 -->
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />

    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.MyApplication"
        tools:targetApi="31">
        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
        <activity
            android:name=".DeviceListActivity"
            android:configChanges="orientation|keyboardHidden"
            android:label="@string/select_device"
            android:theme="@android:style/Theme.Holo.Dialog"/>
        <service android:name="com.example.myapplication.MyService" />
    </application>
</manifest>

Bluetooth接続の実機テスト

Bluetoothの接続確認を、受信内容が確認できるようにAndroid端末とPCを使って受信確認をしたいと思います。PCには今回、ターミナルエミュレータとして「TeraTerm」をインストールし、「Windows を Bluetooth SPP Server として使用する方法」等を参考にTeraTermを設定しました。

  1. PCの設定からBluetoothをオンに、PCの名前を確認
  2. Android端末とペア設定
  3. TeraTerm の「新しい接続」画面で「Bluetoothリンク経由の標準シリアル(COM*)」で「OK」を押下
  4. Android端末の今回のアプリのオプションメニューから、上のPCの名前を探して選択
  5. アプリの「START」ボタンを押す

すると、BluetoothConnectionConnectThreadrun()に設定どおり、"Hello!"とPC側のTeraTermの画面に表示され、i = 0からカウントアップした表示が続き、PCに文字列が送信できたことが分かりました。
Teratermで受信
また、途中で「STOP」ボタンを押すと、カウントアップは中止されますし、接続先を選択しないで「START」ボタンを押すと、設定どおり「Bluetooth接続先が選択されていません」の表示が出ることも確認できました。

参考

9
10
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
10