電車内で――静かな図書室で――会議室で――
ふとアプリを立ち上げたとき、大音量で音が鳴ってしまった。そんな経験はありませんか?
幸い私はまだ経験していませんが、何度も音量を確認してしまうクセが付いてしまいました。
そんな問題を解決すべく、アプリを作りました。
DoNotSpeak: スピーカーで音楽を再生させないアプリ
オープンソース(MITライセンス)です。
リポジトリ
何をするのか
このアプリは、イヤホンが接続されていないときにスピーカー(STREAM_MUSIC)の音量をゼロに設定します。
次のようなタイミングで音量をゼロにします。
- 本アプリを起動したとき
- 音量が変更されたとき
- イヤホンとの接続を解除したとき
- 画面をオフにしたとき(一時解除用)
一時解除機能
このアプリは通知領域に常駐(フォアグラウンドサービス)し、常にスピーカーの音量をゼロにしようとします。ですが、スピーカーを使いたいときもあると思いますので、ミュート処理を一時的に解除する機能を実装しています。
通知のDoNotSpeak!をタップすると解除用のダイアログが表示されます。
技術的詳細
上記だけですと宣伝乙な記事になってしまうので実装内容を書きます。
音量設定
スピーカーの音量をゼロにする、このアプリの根幹部分です。AudioManagerクラスで設定できます。
AudioManager audioManager = this.getSystemService(this, AudioManager.class);
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, 0, 0);
AudioManager
を取得してsetStreamVolume
メソッドを呼び出すだけです。Androidでは音量設定がストリームごとに分かれており、それぞれの音量を独立して設定できます。このアプリでは音楽のみをゼロにするため、STREAM_MUSIC
を指定します。
また、android.permission.MODIFY_AUDIO_SETTINGS
パーミッションが必要そうに見えますが、不要でした。
イヤホン接続状態
このアプリでは、イヤホンを接続しているときは音量をゼロにしないので、イヤホンの接続状態を調べる必要があります。AudioManager
で接続状態を調べることができますが、Androidのバージョンによって判定コードが変わります。Android 6.0 Mより前のAndroidではisWiredHeadsetOn
、isBluetoothScoOn
、isBluetoothA2dpOn
メソッドを使用し、Android 6.0 MからはgetDevices
メソッドを使用します。
private boolean isHeadsetConnected() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
return this.audioManager.isWiredHeadsetOn() || this.audioManager.isBluetoothScoOn() || this.audioManager.isBluetoothA2dpOn();
} else {
AudioDeviceInfo[] devices = this.audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS);
for (int i = 0; i < devices.length; i++) {
AudioDeviceInfo device = devices[i];
int type = device.getType();
if (type == AudioDeviceInfo.TYPE_WIRED_HEADSET
|| type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES
|| type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP
|| type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO
) {
return true;
}
}
return false;
}
}
Bluetooth A2DPは一般的なBluetoothイヤホンで使用されているようですが、Bluetooth SCOは要らないかもしれません。
音量変更検出
せっかく音量をゼロにしてもユーザーや他のアプリが音量を変更してきては意味がありません。ですので、音量が変更されたときに音量をゼロにする機能が必要です。AndroidではContent ProviderにContentObserverを登録することで音量の変更を検出できます。
public final class DNSContentObserver extends ContentObserver {
private Runnable callback;
public DNSContentObserver(Handler handler, Runnable callback) {
super(handler);
this.callback = callback;
}
@Override
public boolean deliverSelfNotifications() {
return false;
}
@Override
public void onChange(boolean selfChange, Uri uri) {
super.onChange(selfChange, uri);
this.callback.run();
}
}
this.getContentResolver().registerContentObserver(
android.provider.Settings.System.getUriFor("volume_music_speaker"),
true,
new DNSContentObserver(new Handler(), new Runnable() {
@Override
public void run() {
// ミュート処理
}
}));
getContentResolver
でContentResolver
クラスを取得し、registerContentObserver
メソッドでContentObserverを登録します。このとき、コンテンツURIという文字列を使用し、どのデータを監視するのかを指定できます。このアプリではスピーカーの音量の変化を検出したいのでcontent://settings/system/volume_music_speaker
を指定しています。
デバウンス処理
上記の音量変化検出処理ですが、短い時間に連続で音量を変更しているとContentObserver
のonChange
が発生しないことがあります(複数の変更が1つのonChange
にまとめられる?)。この影響を緩和するため、onChange
が発生したあとの1秒後に、もう一度ミュート処理を行います。そのためのデバウンス処理をするクラスを作成しました。
public final class Debouncer {
private static final String TAG = "Debouncer";
private final Handler handler;
private final int dueTime;
private final Runnable callback;
private final Runnable checkRunner = new Runnable() {
@Override
public void run() {
Debouncer.this.check();
}
};
private final AtomicBoolean locked = new AtomicBoolean(false);
private int elapsedTime;
private long startTime;
public Debouncer(Handler handler, int dueTime, Runnable callback) {
this.handler = handler;
this.dueTime = dueTime;
this.callback = callback;
this.elapsedTime = this.dueTime;
}
public void update() {
for (; ; ) {
// lock
if (this.locked.compareAndSet(false, true)) {
int elapsed = this.elapsedTime;
// reset time
Log.d(TAG, "reset");
this.startTime = System.currentTimeMillis();
this.elapsedTime = 0;
// not running?
if (elapsed >= this.dueTime) {
this.start(this.dueTime);
}
// unlock
this.locked.set(false);
break;
}
}
}
private void start(int delayTime) {
Log.d(TAG, "start");
this.handler.postDelayed(this.checkRunner, delayTime);
}
private void check() {
Log.d(TAG, "check");
// lock
if (this.locked.compareAndSet(false, true)) {
long currentTime = System.currentTimeMillis();
this.elapsedTime += currentTime - this.startTime;
boolean over = this.elapsedTime >= this.dueTime;
// retry
if (!over) {
int remainTime = this.dueTime - this.elapsedTime;
this.startTime = currentTime;
this.start(remainTime);
}
// unlock
this.locked.set(false);
// callback
if (over) this.callback.run();
}
}
}
イヤホンが抜かれたことを検出
Androidでは、イヤホンが抜かれスピーカーで音が出力されてしまう瞬間にandroid.media.AUDIO_BECOMING_NOISY
がブロードキャストされます。
public final class DNSReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
if (action == null) return;
switch (action) {
case AudioManager.ACTION_AUDIO_BECOMING_NOISY: {
// ミュート処理
break;
}
}
}
}
マニフェストでインテントフィルタも書きます。
<intent-filter>
<action android:name="android.media.AUDIO_BECOMING_NOISY" />
</intent-filter>
ちなみに、Android内部でイヤホンの接続状態を更新する前に、このブロードキャストが呼ばれるため、このタイミングでイヤホンが接続されているかを調べてはいけません。このアプリでは、イヤホンの接続状態にかかわらず、強制的にミュートを行います。
リブート
端末を再起動したときやアプリをアップデートしたときはサービスが終了されます。このままでは悲劇が生まれかねませんので自動的にアプリを開始させる必要があります。そんなときはandroid.intent.action.BOOT_COMPLETED
ブロードキャストとandroid.intent.action.MY_PACKAGE_REPLACED
ブロードキャストを受信し、サービスを起動させます。
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
パーミッションも必要です。
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
コードはAUDIO_BECOMING_NOISY
と同じような感じなので省略
ランチャーからサービスを起動
Androidでは、ランチャーからサービスを直接起動することはできません。ランチャーからは(おそらく)アクティビティーのみを起動できます。ですが、このアプリはアクティビティーを表示する必要がないため、アクティビティーを表示しないように調整しました。
AppTheme
を次のように変更します。
<resources>
<style name="AppTheme" parent="android:Theme.Material">
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowNoTitle">true</item>
</style>
</resources>
これはアクティビティーをタイトルバーなしの透過表示するテーマになります。そしてMainActivity
のonCreate
でサービスを起動し、すぐにfinishAndRemoveTask
を呼び出すことで、見た目上アクティビティーが表示されないようにすることができます。
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// サービスを起動
this.finishAndRemoveTask();
this.overridePendingTransition(0, 0);
}
また、overridePendingTransition
を呼び出すことで、遷移アニメーションをキャンセルできるようです。
アプリサイズの最小化
Android Studioでアプリを作成するとAPKサイズが1MBを超えていました。常駐するアプリの場合、メモリの使用量が小さい方が絶対に良いため、アプリサイズが小さくなるように色々と削減しています。
まずProGuardでminifyEnabled
とshirinkResources
をtrue
にします。これをするだけで700KBぐらいになりました。
release {
minifyEnabled true
shrinkResources true
...
}
通常、KB台まで小さくなれば問題はないと思いますが、私は満足せずより小さくしようと思いました。このアプリではdependencies
で指定している依存ライブラリがAPKを膨らませていました。
build.gradle
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.appcompat:appcompat:1.0.0-beta01'
implementation 'androidx.core:core-ktx:1.1.0-alpha03'
implementation 'androidx.constraintlayout:constraintlayout:1.1.2'
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test:runner:1.1.0-alpha4'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.0-alpha4'
}
これは、AndroidXというAndroidの下位互換性を保つためのサポートライブラリであり、一般的なアプリではほぼ必須だと思います。ですが、これらをかなぐり捨てることでAPKを小さくすることができます。(おすすめはしません。)
AndroidXを削除し、自ら下位互換用のコードを書くことで100KB未満のAPKを作成することができました。最終的に26KBぐらいになっています。(ちなみに最初はKotlinで書いていましたが、生成されるバイトコードが微妙だったためJavaに変えています。companion objectとか...)