Android N ふうにダイレクトリプライ機能を実装してみた

こんにちは、大阪開発部のブノアです。

この間、Android N Developer Previewの発表がありました。その中にメッセージ系の通知に直接、コンテキストを変えず、返信できる機能が含まれています。私はとても便利な機能だと感じました。しかしながら、Android Nがリリースされるまでに同じように便利な機能は使えないのか、Android Nに更新されていない端末にあのような便利な機能は提供できないのかと考えました。 同じタイミングで、グーグルのHangoutアプリInboxアプリの最新版にそういう機能が追加されていました!

私も気になって、「ダイレクトリプライ」機能を作ってみました!今回の実験台は弊社が提供しているサイボウズLive TIMELINEアプリにしました。

サイボウズLive TIMELINE(タイムライン)とは?

TIMELINEはサイボウズLiveのスマホ専用アプリです。簡単にまとめるとグループの機能で、グループに参加しているメンバーとのチャットができる場です。
今回は、そのチャットができる機能に対してダイレクトリプライを追加してみたという話になります。

まずは結果のビデオをみせます。とあるウェブサイトをみている最中に仲間からメッセージが届きます。

説明

  1. とあるウェブサイトを閲覧している
  2. 閲覧の最中にTIMELINEアプリの誰かからメッセージが来る
  3. 返信ボタンの利用でメッセージを書いて返信する
  4. このまま、ウェブサイト閲覧の継続ができる
  5. また、TIMELINEの人からメッセージが来る
  6. いいねボタンで賛成している事を送信する
  7. このまま、ウェブサイト閲覧の継続ができる

ダイレクトリプライ機能のメリット

普段は返信やいいね送信をするには、アプリの切り替えが必要です。よって、上記の説明が7つステップじゃなくて、11ステップになります。「ウェブサイト→TIMELINEにて返信→ウェブサイト→TIMELINEにていいね→ウェブサイト」のような流れになるからです。
アプリ切り替えずにアクションができる事は自分がやっている事の邪魔にならず、集中ができるし、アプリ切り替えに必要なメモリ、パフォーマンスも不要となり、端末の電池に優しい仕組みです。

作る方法

さて、どうやってダイレクトリプライを作ったか説明します。

要件

  • 通知に「返信」・「いいね」できるアクションボタンの追加
  • 「返信」を押すとコンテキスト切り替えなく、返信できる機能
  • 「いいね」を押すとこのまま、いいねが送信される機能

更に「最近のアプリ」に影響を与えないものであれば嬉しいです。

通知に「返信」・「いいね」できるアクションボタン

まずはアクションボタンの追加です。
アクションボタンは「文字列+アイコン」の組み合わせで作る事ができます。今回は、「返信」アクションと「いいね」アクションの2つを作りました。

f:id:cybozuinsideout:20160406135234p:plain

コードは既存の通知を生成する部分を編集して、既存の通知に2つのアクションを追加するだけになります。それから各アクションのIntentの定義もします。

// 既存の通知。これにアクションを追加していく。
final NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(GcmIntentService.this);
// メイン通知の定義、省略
/* ... */

// 通知の管理のため、ユニークなIDを定義する
int notificationId = /*...*/;

// 返信アクションを追加する
Intent replyIntent = new Intent(GcmIntentService.this, DirectReplyActivity.class);
replyIntent.putExtra(/* 必要な情報を */);
replyIntent.putExtra("NOTIFICATION_ID", notificationId);
replyIntent.setAction("TIMELINE_DIRECT_REPLY");
PendingIntent replyPendingIntent =
        PendingIntent.getActivity(GcmIntentService.this, 0, replyIntent, PendingIntent.FLAG_CANCEL_CURRENT);
// 通知ビルダーにアクションを追加する
mBuilder.addAction(R.drawable.icon_reply, "返信する", replyPendingIntent);

Intent likeIntent = new Intent(GcmIntentService.this, DirectReplyActivity.class);
likeIntent.putExtra(/* 必要な情報を */);
// 返信アクションと区別できるため。然るべきやり方は他にあるが。※記事の後に話します
likeIntent.putExtra("LIKE_ACTION", true);
likeIntent.putExtra("NOTIFICATION_ID", notificationId);
likeIntent.setAction("TIMELINE_LIKE");
PendingIntent likePendingIntent =
        PendingIntent.getActivity(GcmIntentService.this, 0, likeIntent, PendingIntent.FLAG_CANCEL_CURRENT);
// 通知ビルダーにアクションを追加する
mBuilder.addAction(R.drawable.icon_like, "いいね", likePendingIntent);

// 今まで通り、通知を発行する
NotificationManager mNotificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
mNotificationManager.notify(notificationId, mBuilder.build());

ご覧の通り、2つとものアクションは DirectReplyActivity アクティビティを起動します。

「いいね」アクションIntentの然るべき形について

今回の「いいね」アクションは何も表示せずバックグラウンドでデータをサーバーに送信するだけなのでアクティビティ経由でデータを送信する事は非効率だと思います。正しいやり方は「いいね」アクションが実行されたら、ブロードキャストして専用のレシーバーにて処理をするのが然るべき形だと思います。

「返信」を押すと返信できる機能

今回は半透明のアクティビティを作りました。半透明だと、半透明なアクティビティを開く前に、開いていたアクティビティはまだ見えているため、アクティビティの onStop() が実行されず、ただの onPause() になります。参考:アクティビティのライフサイクル

半透明バックグラウンドは単色でも大丈夫ですが少しでもきれいにするため、色の線形グラデーションのバックグラウンドを設定しました。

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <gradient
        android:angle="90"
        android:startColor="#0052A2D4"
        android:endColor="#FF52A2D4"
        android:type="linear"/>
</shape>

Androidが提供している半透明スタイルを拡張して、上記に作ったバックグラウンドを使います。

<style name="Theme.Timeline.Transparent" parent="android:Theme.Translucent.NoTitleBar">
    <item name="android:windowBackground">@drawable/direct_reply_background</item>
</style>

最後に AndroidManifest にてダイレクトリプライアクティビティに使います。

<activity
    android:name=".ui.view.activity.DirectReplyActivity"
    android:excludeFromRecents="true" ← 「最近のアプリ」に影響を与えないため
    android:launchMode="singleTask"
    android:screenOrientation="portrait"
    android:theme="@style/Theme.Timeline.Transparent" ← 作ったテーマを定義する
    android:windowSoftInputMode="stateAlwaysVisible" ← 返信用の入力項目が表示されたらキーボードが出したいため
 />

アクティビティのレイアウト

ゴールは下記のスクリーンショットの通りです。

f:id:cybozuinsideout:20160406140026p:plain

説明

  • 上左の矢印:アクティビティを閉じるためのバックボタン
  • 上右のアイコン:やっぱり長いメッセージや添付付けたい場合、TIMELINE自体で返信できるようの移動ボタン
  • 入力項目:返信メッセージかける入力項目
  • 入力項目の右アイコン:送信するボタン

従ってレイアウトは下記の通りに書きました。※ 長くなるので、細かいデザインの記述は省略しています。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    android:id="@+id/direct_reply_layout">

    <LinearLayout
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal">

        <!-- アクティビティを閉じる用のボタン -->
        <ImageView
            android:id="@+id/direct_reply_go_back" />

        <!-- アクティビティのタイトル -->
        <TextView
            android:id="@+id/direct_reply_title_text"
            android:text="[Group]に返信しておく" />

        <!-- やっぱりTIMELINEで返信したい場合、
            TIMELINEアプリを開くボタン -->
        <ImageView
            android:id="@+id/direct_reply_open_up" />
    </LinearLayout>

    <LinearLayout
        android:orientation="horizontal">

        <!-- 返信の入力項目 -->
        <EditText
            android:id="@+id/direct_reply_message"
            android:hint="返信を送信" />

        <!-- 送信ボタン -->
        <ImageView
            android:id="@+id/direct_reply_send_message" />
    </LinearLayout>
</LinearLayout>

アクティビティの中身

やりたい事は

  1. Intentの確認と通知の既読化
  2. 「いいね」アクションの場合、いいねを送信し、アクティビティを停止する
  3. 「返信」アクションの場合、レイアウトの定義、クリックイベント処理設定

1. Intentの確認

Androidでは通知のアクションボタンをクリックしても通知自体は既読になりません。そのため、手動で既読化する事が必要で、既読にするため、通知のアクションのIntent作成時に notificationId を設定しています。

protected void onCreate(Bundle savedInstanceState) {
    /* ... */

    Intent intent = getIntent();
    // 通知IDを取得し、デフォルト値は-1に設定
    int notificationId = intent.getIntExtra("NOTIFICATION_ID", -1);
    // 通知を既読する
    NotificationManager manager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
    manager.cancel(notificationId);

    /* ... */
}

それから必要な情報を取得する。TIMELINEの場合は、どのグループからメッセージが来たのかを保持しています。

2. 「いいね」アクションの場合

「いいね」アクションの場合は、このまま対象のグループにいいねを送信し、アクティビティを閉じます。

protected void onCreate(Bundle savedInstanceState) {
    /* ... */

    // いいねアクションかどうかを確認する。デフォルト値でそうでない
    boolean isLikeAction = intent.getBooleanExtra("LIKE_ACTION", false);
    if (isLikeAction) {
        // サーバーにいいねアクションを送信する
        sendLikeAction();
        // サーバーに最新の既読のメッセージを設定する
        updateReadMessages();
        // アクティビティを閉じる
        this.finish();
    }

    /* ... */
}

3. 「返信」アクションの場合

「返信」アクションの場合は、レイアウトを定義し、表示します。グループ名のよって、タイトルも設定します。

public class DirectReplyActivity extends Activity implements View.OnClickListener {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        Intent intent = getIntent();
        // グループ情報を取得
        group = (Group) intent.getSerializableExtra(BundleKey.GROUP);
        // レイアウトを定義
        setContentView(R.layout.direct_reply);

        // グループ名をタイトルに設定
        TextView viewTitle = (TextView) findViewById(R.id.direct_reply_title_text);
        viewTitle.setText(getString(R.string.direct_reply_title, group.getName()));

        // すべてのボタンのクリックイベント設定
        ImageView openUpButton = (ImageView) findViewById(R.id.direct_reply_open_up);
        openUpButton.setOnClickListener(this);
        ImageView goBackButton = (ImageView) findViewById(R.id.direct_reply_go_back);
        goBackButton.setOnClickListener(this);
        sendMessageButton = (ImageView) findViewById(R.id.direct_reply_send_message);
        sendMessageButton.setOnClickListener(this);
        // デファクトで入力項目が空なので送信ボタンは無効とする
        sendMessageButton.setClickable(false);
    }

    @Override
    public void onClick(View view) {
        int id = view.getId();
        switch (id) {
            case R.id.direct_reply_open_up:
                // TIMELINE自体で書きたい時ようのアクション
                // 入力中のものも再利用のため、Intentに設定する
                Intent intent = new Intent(getApplicationContext(), TimelineActivity.class);
                intent.putExtra("GROUP_ID", groupId);
                intent.putExtra("TEXT_CONTENT", messageText.getText().toString());
                // ダイレクトリプライがTIMELINEのヒストリースタックに残らないようにする
                intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
                startActivity(intent);
                break;
            case R.id.direct_reply_send_message:
                // メッセージを送信する
                sendMessage();
                // サーバーに最新の既読のメッセージを設定する
                updateReadMessages();
                break;
            case R.id.direct_reply_go_back:
                // 何もせずアプリを閉じる
                this.finish();
                break;
        }
    }
}

判明した事

実装を始める前に本当に通常の Activity で行けるのかと少し疑問はあったのですが、大丈夫でした。また、ダイレクトリプライが適応されたアプリケーションのバックスタックにも、最近のアプリにも影響を与えないようにできたのでよかったと思います。
Android N ではない端末でもダイレクトリプライ機能の提供ができるように、Android が最新でない状態でもまだまだ考えられていない機能がいっぱいあるのではないかと思いました!

最後に

説明は以上となります。意外と便利な機能を低コストで作れました!
便利な機能を素早く提供できるよう新しい技術に引き続きチャレンジしていきます!

以上、ブノアでした!