はじめに
この記事は、クソアプリ Advent Calendar の22日目の記事です。
今年も22日担当だったので、来年も22日担当大臣になりたい(願望)
ヒャッハーなアプリを作ったら賞をもらった話
おしおきアプリを作ったら静岡市から賞をもらった話
どうもこんにちは!1年ぶりですね!!!
床下漏水して25万請求されたり(減免措置された)、
大腸憩室炎になって入院しかけたり(投薬治療で回避した)、
腿裏あたりに謎の潰瘍ができて感染症起こしたり(現在、めちゃしみる軟膏で治療中)…
とにかく__怒涛の厄__が襲ってきて、ホント__全然元気じゃない__んですけど、
2017年8月21日 に公開された Android 8.0 Oreo の中で
__全く話題にならない面白そうな機能__が追加されたので、それ使ってクソアプリを作ろうとしました。
作ろうとしました。
という話をします。
やりたいこと
・acceptRingingCall を使ってフリーダイヤルの電話を適当にあしらいたい
・がんばる
アプリ作成の経緯
私、主に水の定期購入のためにコー○デリと契約してるんですよ。
この○ープデリ…とにかく__電話が多い!__
毎週毎週注文してもしなくても注文日1日前に電話してくるし、
なんか特集があると電話してくるし、なんかサービスあると電話してくるし…
やかましい!!! (コープ○リさんごめんなさい)
ということで、なんかうまいこと電話減らせないかなと思って日々を過ごしてました。
そんな時、Android 8.0 Oreo が公開されたので、
Android エンジニアとしてはこう…機能を一通り覗くじゃないですか?
そしたら、みんなホントに全然騒いでないんですけど、
acceptRingingCall メソッドを見つけたんですね?
このメソッド、なんと__自動で電話に応答してくれる__んですよ!
昔、仕事で電話アプリいじってたことあるんですけど、
その当時の着信の取り方って、ものすごく裏技みたいなことしてて、
ヘッドセット着けたよ的なイベントを偽装してたんですよ。
ところが!この! acceptRingingCall メソッドは!呼ぶだけでいい!!!
よっしゃってことで、フルスロットルで機能を考えて、
フリーダイヤルの電話(主にコープデ○)を __本気出してあしらう__ことにしたわけです!ヒャッハー!!
アプリ紹介
アプリ名はその名も「Calling Impossible」!!
一応、動画撮ったんですけど、どう公開するものか悩んだ結果、ドライブにおくことにしましたw
こいつは、以下の機能を有しています。
あしらいたい電話番号を登録する
※ すみません、データをロストしました…orz
24日中に作成し直します…。
あしらいたい電話番号からの着信に自動応答する
簡単に自動応答できるんです。そう、acceptRingingCall ならね!
ってことで、着信を受け取ったら登録されている電話番号と比較して、
自分の出番だなーと思ったら応答します。
以下のセリフで応答してます。
「この電話は受け付けることができない。
例によって、君、もしくは君のメンバーがかけ直し、あるいは番号を変えても、
当局は一切関知しないからそのつもりで。成功を祈る。
なお、この電話は5秒後に手動でブロックされる。」
宣言通り5秒後に手動でブロックするための準備をする
通話中の電話番号を取得し、クリップボードにコピー後、
ブロックしている電話番号一覧に自動的に遷移します。 便利!
この画面は自作のものではなく、端末が設定として用意している画面です。
なお、この機能は Android 7.0 Nouget から追加された処理を利用しています。
プログラム解説
まずはパーミッションの定義から
acceptRingingCall にはパーミッションの設定が必要です。
以下をマニフェストファイルに追記します。
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
acceptRingingCall として必要なパーミッションは ANSWER_PHONE_CALLS のみですが、
どのみち自信が着信中か判定しないといけないので、READ_PHONE_STATE も追加します。
レシーバーの定義
電話の状態を取得する BroadcastReceiver を定義します。
PhoneStateListener でも取得できますが、
BroadcastReceiver だけで完結した方がシンプルになるかなと思って、今回は使用していません。
マニフェストファイルの定義は、こんな感じ。
<receiver
android:name=".receiver.CallPhoneReceiver">
<intent-filter>
<action android:name="android.intent.action.PHONE_STATE" />
</intent-filter>
</receiver>
中身は、こんな感じ。
package com.example.callingimpossible.receiver;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import com.example.callingimpossible.helper.CallingImpossibleHelper;
/**
* 電話のステータスを処理するブロードキャストレシーバー
*
* Created by akitaika_ on 2017/12/17.
*/
public class CallPhoneReceiver extends BroadcastReceiver {
/**
* フリーダイヤルの電話をあしらうために本気出してるヘルパークラス
*/
private static CallingImpossibleHelper sHelper;
@Override
public void onReceive(Context context, Intent intent) {
if (intent == null) {
return;
}
final Bundle extras = intent.getExtras();
if (extras == null) {
return;
}
if (sHelper == null) {
sHelper = new CallingImpossibleHelper(context);
}
sHelper.onReceived(context, extras);
if (sHelper.isFinished()) {
sHelper = null;
}
}
}
中身をゴリゴリ書く
ゴリゴリ。
package com.example.callingimpossible.helper;
import android.content.Context;
import android.os.Bundle;
import android.support.annotation.NonNull;
import android.telephony.TelephonyManager;
import android.text.TextUtils;
import com.example.callingimpossible.handler.CallingImpossibleHandler;
import com.example.callingimpossible.util.SPUtil;
import java.util.Date;
/**
* フリーダイヤルの電話をあしらうために本気出してるヘルパークラス
*
* Created by akitaika_ on 2017/12/17.
*/
public class CallingImpossibleHelper {
public static final String SP_KEY_CALLING_NUMBER = "calling_number";
public static final String SP_KEY_START_CALLING_DATE = "start_calling_date";
public static final String SP_KEY_LAST_CALL_STATE = "last_call_state";
/**
* 終了したフラグ
*/
private boolean mFinished;
/**
* ハンドラー
*/
private CallingImpossibleHandler mHandler;
/**
* コンストラクタ
*/
public CallingImpossibleHelper(Context context) {
mFinished = false;
// 初期化ついでに念のため一時保存してた諸々をクリアしておく
clear(context);
mHandler = new CallingImpossibleHandler(context);
}
/**
* BroadcastReceiver の onReceive のタイミングで呼ぶ
*
* @param context コンテキスト
* @param extras intent の extras
*/
public void onReceive(@NonNull final Context context, @NonNull final Bundle extras) {
// 電話の状態を取得
final String stateString = extras.getString(TelephonyManager.EXTRA_STATE);
final int state;
if (TextUtils.equals(stateString, TelephonyManager.EXTRA_STATE_IDLE)) {
state = TelephonyManager.CALL_STATE_IDLE;
} else if (TextUtils.equals(stateString, TelephonyManager.EXTRA_STATE_OFFHOOK)) {
state = TelephonyManager.CALL_STATE_OFFHOOK;
} else if (TextUtils.equals(stateString, TelephonyManager.EXTRA_STATE_RINGING)) {
state = TelephonyManager.CALL_STATE_RINGING;
} else {
state = -1;
}
// 前回の状態を取得し、変わってたときだけ処理を呼び出す
final int lastState = SPUtil.loadInt(context, SP_KEY_LAST_CALL_STATE);
if (state != lastState) {
onCallStateChanged(context, extras, state);
// 諸々の処理後は保存してた状態を更新する
SPUtil.saveInt(context, SP_KEY_LAST_CALL_STATE, state);
}
}
/**
* 電話の状態が変わったときに呼ばれる
*
* @param context コンテキスト
* @param extras intent の extras
* @param state 電話の状態
*/
public void onCallStateChanged(final Context context, final Bundle extras, final int state) {
switch (state) {
case TelephonyManager.CALL_STATE_RINGING: {
// 着信中
// 電話番号を取得
final String phoneNumber = extras.getString(TelephonyManager.EXTRA_INCOMING_NUMBER);
// SharedPreferences に一時保存
if (!TextUtils.isEmpty(phoneNumber)) {
SPUtil.saveString(context, SP_KEY_CALLING_NUMBER, phoneNumber);
}
// 1秒後に自動で応答
mHandler.sendMessageDelayed(CallingImpossibleHandler.sAcceptRingingCall, 1000);
// FIXME:この方法は、キャッチホンできるやつだとダメそうな気がする…
break;
}
case TelephonyManager.CALL_STATE_OFFHOOK: {
// 通話開始 or 保留
// 通話開始のタイミングを保存日時の有無で判定
final long startDateLong = SPUtil.loadLong(context, SP_KEY_START_CALLING_DATE);
if (startDateLong == -1) {
SPUtil.saveLong(context, SP_KEY_START_CALLING_DATE, new Date().getTime());
// acceptRingingCall の方はキャンセルする
mHandler.removeMessages(CallingImpossibleHandler.sAcceptRingingCall);
}
// FIXME:保留ってどうやって判別すればいいんだろう?
break;
}
case TelephonyManager.CALL_STATE_IDLE: {
// 終了
// 前回の状態を取得
final int lastState = SPUtil.loadInt(context, SP_KEY_LAST_CALL_STATE);
// 着信からそのまま切った or 不在着信
if (lastState == TelephonyManager.CALL_STATE_RINGING) {
// 終了処理
finish(context);
return;
}
// 通話開始の日時が残っているタイミングで呼ばれた=通話終了とみなす
final long startDateLong = SPUtil.loadLong(context, SP_KEY_START_CALLING_DATE);
if (startDateLong != -1) {
// 終了処理
finish(context);
return;
}
break;
}
}
}
/**
* 一連の処理が終了したか
*
* @return 終了した/終了してない
*/
public boolean isFinished() {
return mFinished;
}
/**
* 終了処理
*
* @param context コンテキスト
*/
private void finish(final Context context) {
mFinished = true;
clear(context);
mHandler.destroy();
mHandler = null;
}
/**
* 永続化してた諸々をクリア
*
* @param context コンテキスト
*/
private void clear(final Context context) {
// 一時保存してたものを削除
SPUtil.remove(context, SP_KEY_CALLING_NUMBER);
SPUtil.remove(context, SP_KEY_START_CALLING_DATE);
}
}
ゴリゴリゴリ。
package com.example.callingimpossible.handler;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.media.AudioAttributes;
import android.media.AudioManager;
import android.os.Handler;
import android.os.Message;
import android.telecom.TelecomManager;
import com.example.callingimpossible.R;
import com.example.callingimpossible.util.SPUtil;
import com.example.callingimpossible.helper.CallingImpossibleHelper;
import com.example.callingimpossible.helper.MediaPlayerHelper;
/**
* フリーダイヤルの電話をあしらうために本気出してるハンドラー
*
* Created by akiaika_ on 2017/12/21.
*/
public class CallingImpossibleHandler extends Handler implements MediaPlayerHelper.Callback {
/**
* acceptRingingCall を呼び出すための定数
*/
public static final int sAcceptRingingCall = 254;
/**
* 音声を流して応答するための定数
*/
public static final int sPlayCallingImpossible = 255;
/**
* 後処理を実施するための定数
*/
public static final int sFinish = 256;
/**
* コンテキスト
*/
private Context mContext;
/**
* destroy が呼ばれたか
*/
private boolean mDestroyed;
/**
* 音楽再生ヘルパークラス
*/
private MediaPlayerHelper mMediaPlayerHelper;
/**
* コンストラクタ
*
* @param context コンテキスト
*/
public CallingImpossibleHandler(Context context) {
mContext = context;
mDestroyed = false;
mMediaPlayerHelper = new MediaPlayerHelper(context, this);
}
@Override
public void onComplete(int rawResId) {
switch (rawResId) {
case R.raw.calling_impossible:
// 音声再生後、きっちり5秒後に終了
sendMessageDelayed(sFinish, 5000);
break;
default:
break;
}
}
@Override
public void handleMessage(Message msg) {
if (mDestroyed) {
return;
}
switch (msg.what) {
case sAcceptRingingCall: {
// 応答する
// すでに電話とってた場合は、無視する
final long startDateLong = SPUtil.loadLong(mContext, CallingImpossibleHelper.SP_KEY_START_CALLING_DATE);
if (startDateLong != -1) {
return;
}
// 自動で取る!!
final TelecomManager telecomManager = (TelecomManager) mContext.getSystemService(Context.TELECOM_SERVICE);
if (telecomManager == null) {
return;
}
telecomManager.acceptRingingCall();
// そして、ちょっとラグおいてから音声を流す
sendMessageDelayed(sPlayCallingImpossible, 2000);
break;
}
case sPlayCallingImpossible: {
// 音声を再生する
// 用意しておいた音声がこちらです
final AudioAttributes audioAttributes = new AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.setLegacyStreamType(AudioManager.STREAM_VOICE_CALL)
.build();
mMediaPlayerHelper.play(R.raw.calling_impossible, audioAttributes);
break;
}
case sFinish: {
// 手動でブロックできるように誘導する
// その前に対象の電話番号は、クリップボードに保持する
final String phoneNumber = SPUtil.loadString(mContext, CallingImpossibleHelper.SP_KEY_CALLING_NUMBER);
final ClipboardManager clipboardManager = (ClipboardManager) mContext.getSystemService(Context.CLIPBOARD_SERVICE);
if (clipboardManager != null) {
final ClipData clipData = ClipData.newPlainText("calling_impossible", phoneNumber);
clipboardManager.setPrimaryClip(clipData);
}
// ブロックしている番号一覧に誘導する
final TelecomManager telecomManager = (TelecomManager) mContext.getSystemService(Context.TELECOM_SERVICE);
if (telecomManager != null) {
mContext.startActivity(telecomManager.createManageBlockedNumbersIntent());
}
break;
}
default:
break;
}
}
/**
* メッセージの送信
*
* @param what Message に設定する what
* @param delayMillis 遅延させる時間(ミリ秒)
*/
public void sendMessageDelayed(final int what, final long delayMillis) {
final Message message = Message.obtain();
message.what = what;
sendMessageDelayed(message, delayMillis);
}
/**
* 破棄処理
*/
public void destroy() {
mDestroyed = true;
mContext = null;
mMediaPlayerHelper.finish();
removeCallbacksAndMessages(null);
}
}
※ SPUtil と MediaPlayerHelper は自作クラスです
めんどくさかったから、つい一時保存ファイルを SharedPreference に保存しちゃったけど、
こいつをそういう用途に使うのはどうかと思う(真顔)
できなかったこと
自動で電話が切れない
ホントはブロックとは別機能的な位置付けにいたくて、
「応答めんどくさい」みたいなのに対応できたらなーと思ってたんですけど、
電話出るやつは用意してくれたのに、 電話切るやつは用意されてない。
くそー、わかるけどさー…わかるけどさぁ!
一応、以下のようにリフレクション使って隠されてるメソッドにアクセスしてみたんですけど、
見事に SecurityException くらいましたよね!w
try {
final TelecomManager telecomManager = (TelecomManager) context.getSystemService(Context.TELECOM_SERVICE);
if (telecomManager == null) {
return;
}
final Class telecomManagerClass = Class.forName(telecomManager.getClass().getName());
final Method endCallMethod = telecomManagerClass.getDeclaredMethod("endCall");
endCallMethod.invoke(telecomManager);
} catch (Exception e) {
// java.lang.SecurityException: Neither user 10083 nor current process has android.permission.MODIFY_PHONE_STATE.
}
MODIFY_PHONE_STATE は、システムアプリにしか使えないパーミッションらしいので、
このアプリがシステムアプリになればあるいは…!
って感じでした。まる。
自動でブロックもできない
自動で終話できないなら、__自動でブロックすればいいじゃない!__って思いました。
ダメでした(・ω<)テヘペロ
try {
final String phoneNumber = SPUtil.loadString(context, "calling_number");
final ContentResolver contentResolver = context.getContentResolver();
ContentValues values = new ContentValues();
values.put(BlockedNumberContract.BlockedNumbers.COLUMN_ORIGINAL_NUMBER, phoneNumber);
contentResolver.insert(BlockedNumberContract.BlockedNumbers.CONTENT_URI, values);
} catch (Exception e) {
// java.lang.SecurityException: Caller must be system, default dialer or default SMS app
}
これもシステムアプリしかダメなようです。
まぁ、そりゃ自由に使えたら簡単に悪用できちゃうしなぁ…。
セリフが受話器から聞こえない
この問題は、 9割作った後に気づきました。
Android ってほら、音声にストリームタイプってあるじゃないですか。
あの、通話と音楽再生のね?タイプが分かれてるとさ、その… 干渉できないじゃないですか!
つまり、一見うまく応答できてるかのように見えますが、
__通話している相手には一切聞こえてない__んですね!
もうね、__ただの無言電話__です、本当にありがとうございました。
一応、以下のようにすればイケるのかなと思ったんですけど、
ちょっと手元に試せる環境がすぐ用意できなかったので、まだ調査中という段階に…。
AudioAttributes attrs = new AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.setLegacyStreamType(AudioManager.STREAM_VOICE_CALL)
.build();
mediaPlayer.setAudioAttributes(attrs);
mediaPlayer.start();
どうなんだろう…技術的に不可能だったりするのかな;
まとめ
- フル機能作ることができたとしても実用性ないよ!
- せっかく準備したのに、最近電話こないよ!
- セリフ考えてる時が一番楽しかったよ!
参考
- Android PhoneStateListener/ Phone Call Broadcast Receiver Tutorial
https://www.studytutorial.in/android-phonestatelistener-phone-call-broadcast-receiver-tutorial - TelecomManager | Android Developers
https://developer.android.com/reference/android/telecom/TelecomManager.html - BlockedNumberContract | Android Developers
https://developer.android.com/reference/android/provider/BlockedNumberContract.html - TelecomManager.java
https://android.googlesource.com/platform/frameworks/base/+/a0d3ca9/telecomm/java/android/telecom/TelecomManager.java - ゆくも!
http://www.yukumo.net/