2012/03/16

Android:緊急呼発信時のシーケンス

前回に続き、今回は緊急呼発信時のシーケンスです。

通常時の緊急呼発信シーケンスでもよいのですが、本稿では機内モード設定時の緊急呼発
信シーケンスを見ていきます。

処理を追う前に、下記の前提知識です。
  • 緊急呼発信は3rdパーティアプリから利用することが出来ない
  • 緊急呼は機内モード設定時でも発信を試みる
緊急呼は命の危険に関わる為、扱いは慎重に行う必要があります。

前者の仕様に関して、通常呼では3rdパーティアプリが音声発信シーケンスの一部に関与
することができましたが、緊急呼ではそれができません。
ただし、ACTION_NEW_OUTGOING_CALLのブロードキャストは緊急呼発信時も送信されるので、
これを検知することは可能ですが、緊急呼発信処理の妨げとならないよう細心の注意が
必要です。

後者の仕様に関して、緊急呼は機内モードを解除して発信しようとします。
# 下記は解除→発信時の画面



緊急呼のテストはエミュレータや擬似網環境で行う必要があります。
実網を使って緊急呼をテストしてはいけません。
今回はエミュレータで緊急呼テストしています。
デフォルトでエミュレータの緊急通報番号は911です。
好きな番号を緊急通報番号として判定させたい場合はisEmergencyNumberがtrueを返す
ように下記のコマンドを実行します。

# 110番を緊急呼番号として登録
adb shell setprop ril.ecclist 110

緊急呼発信時のシーケンスも通常呼発信時とほぼ同じです。


●緊急呼発信の開始
通常呼発信と同じくaction.CALLのIntentをOutgoingCallBroadcasterが受信します。
ただし、ここで緊急通報番号と判断されれば緊急呼発信シーケンスに移ります。

・com.android.phone.OutgoingCallBroadcaster.java
protected void onCreate(Bundle icicle) {
    // If true, this flag will indicate that the current call is a special kind
    // of call (most likely an emergency number) that 3rd parties aren't allowed
    // to intercept or affect in any way.  (In that case, we start the call
    // immediately rather than going through the NEW_OUTGOING_CALL sequence.)
    boolean callNow;

    } else if (Intent.ACTION_CALL_EMERGENCY.equals(action)) {
        callNow = true;
    }

    if (callNow) {
        // This is a special kind of call (most likely an emergency number)
        // that 3rd parties aren't allowed to intercept or affect in any way.
        // So initiate the outgoing call immediately.

        PhoneApp.getInstance().callController.placeCall(intent);

        // Note we do *not* "return" here, but instead continue and
        // send the ACTION_NEW_OUTGOING_CALL broadcast like for any
        // other outgoing call.  (But when the broadcast finally
        // reaches the OutgoingCallReceiver, we'll know not to
        // initiate the call again because of the presence of the
        // EXTRA_ALREADY_CALLED extra.)
    }

    PhoneUtils.checkAndCopyPhoneProviderExtras(intent, broadcastIntent);
    broadcastIntent.putExtra(EXTRA_ALREADY_CALLED, callNow);
    sendOrderedBroadcast(broadcastIntent, PERMISSION, new OutgoingCallReceiver(),
                         null,  // scheduler
                         Activity.RESULT_OK,  // initialCode
                         number,  // initialData: initial value for the result data
                         null);  // initialExtras
}

緊急通報番号と判断されるとcallNowフラグがtrueになり、即placeCallが呼ばれ発信動作
に移ります。
コメントにもあるように、緊急通報番号と判定されても即リターンはされず、
通常呼発信シーケンスと似たようなプロセスを辿らせてACTION_NEW_OUTGOING_CALL
ブロードキャストIntentが投げられます。

通常呼シーケンスではACTION_NEW_OUTGOING_CALLをOutgoingCallReceiverが受信した後、
音声発信動作に移りますが、緊急呼発信シーケンスの場合、OutgoingCallReceiverが受信
する時点では既に緊急呼が発信されている為、発信処理をキャンセルする必要があります。

そのため、callNow変数をEXTRA_ALREADY_CALLEDに格納してOutgoingCallReceiver側でこれ
を参照し処理を中断します。

・com.android.phone.OutgoingCallBroadcaster$OutgoingCallReceiver
public void doReceive(Context context, Intent intent) {
    boolean alreadyCalled;
    String number;
    String originalUri;

    alreadyCalled = intent.getBooleanExtra(
            OutgoingCallBroadcaster.EXTRA_ALREADY_CALLED, false);
    if (alreadyCalled) {
        if (DBG) Log.v(TAG, "CALL already placed -- returning.");
        return;
    }
}

3rdパーティアプリが通常呼発信時に電話番号(resultData)を編集するかもしれませんが、
緊急呼シーケンスではresultDataの内容は無視されます

緊急呼発信シーケンスに戻り、placeCallの続きを追います。

・com.android.phone.CallController.java
public void placeCall(Intent intent) {
    CallStatusCode status = placeCallInternal(intent);
    mApp.displayCallScreen();
}

private CallStatusCode placeCallInternal(Intent intent) {
    // update okToCallStatus based on new phone
    okToCallStatus = checkIfOkToInitiateOutgoingCall(
            phone.getServiceState().getState());
    if (okToCallStatus != CallStatusCode.SUCCESS) {
        // If this is an emergency call, launch the EmergencyCallHelperService
        // to turn on the radio and retry the call.
        if (isEmergencyNumber && (okToCallStatus == CallStatusCode.POWER_OFF)) {
            Log.i(TAG, "placeCall: Trying to make emergency call while POWER_OFF!");

            // If needed, lazily instantiate an EmergencyCallHelper instance.
            synchronized (this) {
                if (mEmergencyCallHelper == null) {
                    mEmergencyCallHelper = new EmergencyCallHelper(this);
                }
            }

            // ...and kick off the "emergency call from airplane mode" sequence.
            mEmergencyCallHelper.startEmergencyCallFromAirplaneModeSequence(number);

            // Finally, return CallStatusCode.SUCCESS right now so
            // that the in-call UI will remain visible (in order to
            // display the progress indication.)
            // TODO: or maybe it would be more clear to return a whole
            // new CallStatusCode called "TURNING_ON_RADIO" here.
            // That way, we'd update inCallUiState.progressIndication from
            // the handleOutgoingCallError() method, rather than here.
            return CallStatusCode.SUCCESS;
        }
    }
}

もし、機内モード等により、音声発信の準備が整っていないとokToCallStatusはSUCCESS
とならず、機内モードを解除して発信を成功させようとするシーケンスに入ります。
このシーケンスではEmergencyCallHelperがメインとなります。

・com.android.phone.EmergencyCallHelper.java
public class EmergencyCallHelper extends Handler {
    public void startEmergencyCallFromAirplaneModeSequence(String number) {
        if (DBG) log("startEmergencyCallFromAirplaneModeSequence('" + number + "')...");
        Message msg = obtainMessage(START_SEQUENCE, number);
        sendMessage(msg);
    }

    @Override
    public void handleMessage(Message msg) {
        switch (msg.what) {
            case START_SEQUENCE:
                startSequenceInternal(msg);
                break;
            case SERVICE_STATE_CHANGED:
                onServiceStateChanged(msg);
                break;
            case RETRY_TIMEOUT:
                onRetryTimeout();
                break;
        }
    }

    private void startSequenceInternal(Message msg) {
        // No need to check the current service state here, since the only
        // reason the CallController would call this method in the first
        // place is if the radio is powered-off.
        //
        // So just go ahead and turn the radio on.

        powerOnRadio();  // We'll get an onServiceStateChanged() callback
                         // when the radio successfully comes up.

        // Next step: when the SERVICE_STATE_CHANGED event comes in,
        // we'll retry the call; see placeEmergencyCall();
        // But also, just in case, start a timer to make sure we'll retry
        // the call even if the SERVICE_STATE_CHANGED event never comes in
        // for some reason.
        startRetryTimer();
    }
}

EmergencyCallHelperはHandlerを継承したクラスです。
startSequenceInternalメソッドのpowerOnRadioで機内モードの解除を試みます。
ただし、機内モードが解除できなくてもリトライ処理がstartRetryTimerで準備されます。

・com.android.phone.EmergencyCallHelper.java
private void powerOnRadio() {
    if (DBG) log("- powerOnRadio()...");

    // We're about to turn on the radio, so arrange to be notified
    // when the sequence is complete.
    registerForServiceStateChanged();

    // If airplane mode is on, we turn it off the same way that the
    // Settings activity turns it off.
    if (Settings.System.getInt(mApp.getContentResolver(),
                               Settings.System.AIRPLANE_MODE_ON, 0) > 0) {
        Settings.System.putInt(mApp.getContentResolver(),
                               Settings.System.AIRPLANE_MODE_ON, 0);

        // Post the intent
        Intent intent = new Intent(Intent.ACTION_AIRPLANE_MODE_CHANGED);
        intent.putExtra("state", false);
        mApp.sendBroadcast(intent);
    } else {
        // Otherwise, for some strange reason the radio is off
        // (even though the Settings database doesn't think we're
        // in airplane mode.)  In this case just turn the radio
        // back on.
        if (DBG) log("==> (Apparently) not in airplane mode; manually powering radio on...");
        mPhone.setRadioPower(true);
    }
}

private void startRetryTimer() {
    removeMessages(RETRY_TIMEOUT);
    sendEmptyMessageDelayed(RETRY_TIMEOUT, TIME_BETWEEN_RETRIES);
}

powerOnRadioでは、電波の回復を検知するためにregisterForServiceStateChangedリスナー
が登録されます。

・com.android.phone.EmergencyCallHelper.java
private void registerForServiceStateChanged() {
    // Unregister first, just to make sure we never register ourselves
    // twice.  (We need this because Phone.registerForServiceStateChanged()
    // does not prevent multiple registration of the same handler.)
    mPhone.unregisterForServiceStateChanged(this);  // Safe even if not currently registered
    mPhone.registerForServiceStateChanged(this, SERVICE_STATE_CHANGED, null);
}

電波状態が変化すればSERVICE_STATE_CHANGEDが投げられます。
handleMessageでこれを受け取るとonServiceStateChangedをコールします。
onServiceStateChangedでは電波状態を再確認し、発信可能と判断できれば緊急呼発信を
行います。発信可能と判断されなければ何もしません。

・com.android.phone.EmergencyCallHelper.java
private void onServiceStateChanged(Message msg) {
    ServiceState state = (ServiceState) ((AsyncResult) msg.obj).result;

    // Once we reach either STATE_IN_SERVICE or STATE_EMERGENCY_ONLY,
    // it's finally OK to place the emergency call.
    boolean okToCall = (state.getState() == ServiceState.STATE_IN_SERVICE)
            || (state.getState() == ServiceState.STATE_EMERGENCY_ONLY);

    if (okToCall) {
        // Woo hoo!  It's OK to actually place the call.

        // Deregister for the service state change events.
        unregisterForServiceStateChanged();
        placeEmergencyCall();
    }
}

発信可能と判断されなかった場合は、startRetryTimerで投げたRETRY_TIMEOUTメッセージが
ハンドルされます。
RETRY_TIMEOUTは5秒のディレイが掛けられています。
RETRY_TIMEOUTメッセージを5秒後に受け取るとonRetryTimeoutメソッドをコールします。

・com.android.phone.EmergencyCallHelper.java
private void onRetryTimeout() {
    Phone.State phoneState = mCM.getState();
    int serviceState = mPhone.getServiceState().getState();

    if (serviceState != ServiceState.STATE_POWER_OFF) {
        // Woo hoo -- we successfully got out of airplane mode.
        placeEmergencyCall();  // If the call fails, placeEmergencyCall()
                               // will schedule a retry.
    } else {
        powerOnRadio();  // Again, we'll (hopefully) get an onServiceStateChanged()
                         // callback when the radio successfully comes up.

        // ...and also set a fresh retry timer (or just bail out
        // totally if we've had too many failures.)
        scheduleRetryOrBailOut();
    }
}

onRetryTimeoutでは、リトライ処理を開始する前に電波状態を確認し、有効な場合は緊急
呼発信を試みます。
ただし、電波状態が有効でない場合はscheduleRetryOrBailOutでリトライ処理を開始します。

・com.android.phone.EmergencyCallHelper.java
private void scheduleRetryOrBailOut() {
    mNumRetriesSoFar++;

    if (mNumRetriesSoFar > MAX_NUM_RETRIES) {
        // ...and have the InCallScreen display a generic failure
        // message.
        mApp.inCallUiState.setPendingCallStatusCode(CallStatusCode.CALL_FAILED);
    } else {
        startRetryTimer();
        mApp.inCallUiState.setProgressIndication(ProgressIndicationType.RETRYING);
    }
}

リトライは延々と行われるわけではなく、MAX_NUM_RETRIES-1回試行されます。
# MAX_NUM_RETRIESはデフォルト5なので、4回はリトライ処理されます

mNumRetriesSoFarの値が上限であるMAX_NUM_RETRIESに達していない場合は、再度
startRetryTimerがコールされます。

以上が機内モード設定時の緊急呼発信シーケンスです。
緊急呼発信シーケンスを解析すると
  • 緊急呼発信時は3rdパーティによる電話番号編集の影響を受けない
  • リトライ処理(機内モード解除→発信)の範例
を垣間見ることができました。

中々目にすることのない動作ではあるものの極めて重要な処理です。
3rdパーティは緊急呼発信処理を妨げないように徹底しなければなりません。

以上です。