スピーカーで音楽を再生させたくない人のためのAndroidアプリを作成

電車内で――静かな図書室で――会議室で――
ふとアプリを立ち上げたとき、大音量で音が鳴ってしまった。そんな経験はありませんか？
幸い私はまだ経験していませんが、何度も音量を確認してしまうクセが付いてしまいました。

そんな問題を解決すべく、アプリを作りました。

# DoNotSpeak: スピーカーで音楽を再生させないアプリ
ダウンロードは下記リンクから
<a href='https://play.google.com/store/apps/details?id=io.github.diontools.donotspeak'><img alt='Get it on Google Play' src='https://play.google.com/intl/en_us/badges/images/generic/en_badge_web_generic.png' width="200"/></a>

オープンソース（MITライセンス）です。
[リポジトリ](https://github.com/diontools/DoNotSpeak)

## 何をするのか
このアプリは、イヤホンが接続されていないときにスピーカー（STREAM_MUSIC）の音量をゼロに設定します。
次のようなタイミングで音量をゼロにします。

* 本アプリを起動したとき
* 音量が変更されたとき
* イヤホンとの接続を解除したとき
* 画面をオフにしたとき（一時解除用）

## 一時解除機能
このアプリは通知領域に常駐（フォアグラウンドサービス）し、常にスピーカーの音量をゼロにしようとします。ですが、スピーカーを使いたいときもあると思いますので、ミュート処理を一時的に解除する機能を実装しています。

通知のDoNotSpeak!をタップすると解除用のダイアログが表示されます。
<img src="https://github.com/diontools/DoNotSpeak/raw/master/icons/screenshots/disable_alert.png" width="300"/>

# 技術的詳細
上記だけですと宣伝乙な記事になってしまうので実装内容を書きます。

## 音量設定
スピーカーの音量をゼロにする、このアプリの根幹部分です。[AudioManager](https://developer.android.com/reference/android/media/AudioManager)クラスで設定できます。

```java
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`メソッドを使用します。

```java
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](https://developer.android.com/guide/topics/providers/content-providers)に[ContentObserver](https://developer.android.com/reference/android/database/ContentObserver)を登録することで音量の変更を検出できます。

```java
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`が発生しないことがあります（複数の変更が１つの`onChange`にまとめられる？）。この影響を緩和するため、`onChange`が発生したあとの１秒後に、もう一度ミュート処理を行います。そのための[デバウンス](http://reactivex.io/documentation/operators/debounce.html)処理をするクラスを作成しました。

```java
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`がブロードキャストされます。

```java
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;
            }
        }
    }
}
```

マニフェストでインテントフィルタも書きます。

```xml
<intent-filter>
    <action android:name="android.media.AUDIO_BECOMING_NOISY" />
</intent-filter>
```

ちなみに、Android内部でイヤホンの接続状態を更新する前に、このブロードキャストが呼ばれるため、このタイミングでイヤホンが接続されているかを調べてはいけません。このアプリでは、イヤホンの接続状態にかかわらず、強制的にミュートを行います。

## リブート
端末を再起動したときやアプリをアップデートしたときはサービスが終了されます。このままでは悲劇が生まれかねませんので自動的にアプリを開始させる必要があります。そんなときは`android.intent.action.BOOT_COMPLETED`ブロードキャストと`android.intent.action.MY_PACKAGE_REPLACED`ブロードキャストを受信し、サービスを起動させます。

```xml
<intent-filter>
    <action android:name="android.intent.action.BOOT_COMPLETED" />
    <action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
```

パーミッションも必要です。

```xml
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
```

コードは`AUDIO_BECOMING_NOISY`と同じような感じなので省略

## ランチャーからサービスを起動
Androidでは、ランチャーからサービスを直接起動することはできません。ランチャーからは（おそらく）アクティビティーのみを起動できます。ですが、このアプリはアクティビティーを表示する必要がないため、アクティビティーを表示しないように調整しました。

`AppTheme`を次のように変更します。

```xml
<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`を呼び出すことで、見た目上アクティビティーが表示されないようにすることができます。

```java
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とか...）