はじめに
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-BluetoothChat
の MainActivity
では bluetooth接続以降、Fragment で処理しているので、これを踏襲します。
つぎに、Bluetooth機器の操作にはRuntime Permissionが必要です。必要になってから許可しますか?と聞かれるより、アプリ開始直後にもらった方がいいような気がするので、onCreate
から呼ばれるように追加しておきます。「アプリの権限をリクエストする」などを参考にして追加しました。
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);
}
}
}
}
レイアウト設定の方は、必要最小限にしておきます。
<?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
を新規で作りますが、BluetoothChat
のBluetoothChatFragment
は使わない部分もありそうなので、まずは onCreate
とonStart
、onCreateView
をコピー。
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
でなければ、メッセージをトーストで出して終了、そうでなければ次のユーザー操作なり、イベントを待つことになります。
@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_leaving
は res/values/strings.xml
に android-BluetoothChat
からコピー。これは、スマホの「設定」のBluetoothがOFFのまま、アプリを起動した際に、”「(アプリ名)」がBluetoothを ON にしようとしています”と表示され、許可されない時にトーストで出るメッセージです。
<string name="bt_not_enabled_leaving">Bluetooth was not enabled. Leaving Bluetooth Chat.</string>
許可されるとスマホの「設定」にあるBluetoothがONになります。
接続先の指定
使用するAndroid端末とBluetooth端末(今回はPC)をペア設定する部分はAndroid端末の「設定」にある機能を利用し、アプリではペア設定済みの端末のリストから選択することにします。つまり、アプリ内で独自にBluetooth端末をスキャンしたり、ペア設定する機能は含まないことにします。従って、今回のアプリの接続実験をする際は、接続したいBluetooth端末とは事前にペア設定しておくことをお願いします。
アプリ内での接続先の選択は、BluetoothChat
を踏襲し、画面のオプションメニューから選択できるようにします。
そのためには、BluetoothChatFragment
に、onCreateOptionsMenu
とonOptionsItemSelected
を追加します。
@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
に追加しておきます。
<!-- Options Menu -->
<string name="connect">Connect a device</string>
これで、オプションメニューから「Connect a device」と書かれたメニュー項目を選択すると、DeviceListActivity
が呼ばれて接続先アドレスをユーザーに選択してもらい、その結果が戻ってくるので、String address
で受け取るようにしておきます。
ただし、ここもstartActivityForResult
は”非推奨”なので、ActivityResultLauncher<Intent> mGetBluetoothAddress
というメソッドを作りました。これをstartActivityForResult
の代わりにlaunch
します。
また、R.menu.bluetooth_menu
は、res
ディレクトリに menu
ディレクトリを作り、bluetooth_menu.xml
を新規に作って、BluetoothChat
のres/menu/bluetooth_chat.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のときや値が入っていないときは呼ばないようにし、トーストを出すようにしておきました。
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 ディレクトリに新規で作ります。
<?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
も追加しておきます。
<string name="start">Start</string>
<string name="stop">Stop</string>
<string name="no_btAddress">Bluetooth接続先が選択されていません</string>
画面イメージは以下です。
BluetoothChatFragment のまとめ
元々のBluetoothChat
のBluetoothChatFragment.java
でBluetoothの使用が許可された場合、onActivityResult()
にあるsetupChat()
が実行されていますが、その中身はボタンを押したときの処理等の定義で、前述のように今回はバックグラウンド(ForegroundService)で使いたいので、この部分は使いませんでした。その他、statusの操作や、handlerはとりあえず不要なので使いません。
生き残った部分は、以下のようになりました。
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と同列の場所にコピー。
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;
}
としておくとペア設定済みデバイスリストが表示されると思います。
最後にpairedListView
のsetOnItemClickListener
で渡されているmDeviceClickListener
の設定があります。DeviceListActivity
で画面に表示されたペア設定済みデバイスのリストの中から、今回接続したいデバイスを選択してもらい、Intent
に入れて呼び出しに送り返しています。
res/layout/activity_device_list.xml
も”ペア設定済み”の部分だけBluetoothChat
からコピー。
<?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.xml
もBluetoothChat
からコピー。
<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
に以下を追加
<!-- 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>
後でもう一度出てきますが、新しいアクティビティとして、マニフェストに忘れず追加しておきます。
<activity
android:name=".DeviceListActivity"
android:configChanges="orientation|keyboardHidden"
android:label="@string/select_device"
android:theme="@android:style/Theme.Holo.Dialog"/>
接続先選択画面はこんな感じです。もちろん、ペア設定済みデバイスの数は同じではありませんので、ペア設定済みデバイスの数に応じてリストアップされるデバイスの数は変化します。
BluetoothConnection.java
概要
元々のBluetoothChat
のBluetoothChatService.java
の中身は、start
、connect
等のメソッドと、AcceptThread
、ConnectThread
、ConnectedThread
があります。AcceptThread
は、「Bluetoothの概要」の「サーバー側の接続」にも類似のものが出てきますが、今回はPC側からスマホを接続する構成は考えていませんので使わないことにします。
また、バックグラウンド(ForegroundService)を使う関係で、バックグラウンドで動かすのに必要な処理をまとめたMyService
と、bluetooth接続処理を新しくBluetoothConnection.java
というクラスを作って分割し、MyService
からBluetoothConnection
のメソッドを呼ぶ構成にしました。
ConnectThread
クライアント側として接続する部分を確認します。「Bluetoothの概要」の「クライアント側の接続」と見比べると、BluetoothChat
のBluetoothChatService.java
のConnectThread
の方は、引数にboolean secure
が追加されていて、BluetoothSocket mmSocket
をBluetoothDevice 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 のまとめ
以上の内容をひとつにまとめると、以下のようになりました。
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」ボタンを押すと、MyService
の onDestroy()
が呼ばれるので、ループ処理フラグをOFF(onActive = false
)、for ループ break
となる処理も追加しておきます。
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 の概要にある通り、BLUETOOTH
とACCESS_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も記載しておかないと起動できないので、忘れず追加します。
<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を設定しました。
- PCの設定からBluetoothをオンに、PCの名前を確認
- Android端末とペア設定
- TeraTerm の「新しい接続」画面で「Bluetoothリンク経由の標準シリアル(COM*)」で「OK」を押下
- Android端末の今回のアプリのオプションメニューから、上のPCの名前を探して選択
- アプリの「START」ボタンを押す
すると、BluetoothConnection
のConnectThread
のrun()
に設定どおり、"Hello!"とPC側のTeraTermの画面に表示され、i = 0からカウントアップした表示が続き、PCに文字列が送信できたことが分かりました。
また、途中で「STOP」ボタンを押すと、カウントアップは中止されますし、接続先を選択しないで「START」ボタンを押すと、設定どおり「Bluetooth接続先が選択されていません」の表示が出ることも確認できました。