AndroidのローカルAIで読書ブログ投稿アプリを作ることにした 04

2026年9月14日月曜日

Androidアプリ プログラミング

t f B! P L

読書ブログAI開発記・第4回|写真付きの記事をBloggerへ届ける ― メールとAPIで下書きを仕上げる

前回は、本の情報と自分の感想から、端末内のGemmaで記事データを作るところまで進めた。今回はBloggerとの連携。文章ができたあと、写真をどう置き、どの記事を更新し、どこまで進めば完了なのか。専用アプリとして使うために、送信の流れをつないでいく。
今回のサンプルについて
コードは今回のAndroidアプリの実装から抜粋し、一部は説明用に短く整理しています。単独で完成アプリになるコードではなく、第3回までの記事生成処理と、Googleの認可を受けたアプリを前提にしたものです。個人のブログURL、投稿用アドレス、記事ID、認証情報は掲載していません。

GmailApiClientBloggerApiClientBloggerEmailPostComposerBloggerPendingPostStoreは今回作った自作クラスです。

記事のHTMLができても、写真には置き場所が必要

アプリで選んだ写真は端末内にある。その写真をブログの読者にも見せるには、ブログから参照できる場所へ渡す必要がある。そこで今回採用したのが、写真をBloggerのメール投稿へ送り、その後をBlogger APIで仕上げる方法だった。

文章のHTMLはできた。あとは送信。
写真はまだスマホの中だ。
一緒に送ればいいんじゃないの?
その「一緒に送る」を、今回はメール投稿に任せる。
APIだけで全部やる構成にはしなかったのね。
写真を受け取ってもらうところと、記事本文を仕上げるところで役割を分けた。

メール投稿先はBloggerの設定に対応する専用アドレスを使う。アプリにはブログURLと投稿用メールアドレスを設定し、写真付きメールの送信にはGmail APIを使う構成になっている。

先にBlogger側を「下書きとして保存」にする

Bloggerの設定にあるメール投稿を有効にし、受信したメールを下書きとして保存する設定を選ぶ。今回の処理は下書きを検索するため、即時公開の設定とは組み合わせない。写真をメールへ添付して投稿する方法も公式ヘルプに案内されている。Blogger公式ヘルプ:メールによる投稿

設定値を説明用に書くと、次のようになる。これは実際の送信先ではなく、置き換える場所を示す例である。

ブログURL: https://example.com/
投稿用メールアドレス: blogger-post@example.invalid
メール投稿の扱い: 下書きとして保存
投稿用メールアドレスも、記事にそのまま載せる?
そのアドレスへ送ると投稿できるので、実際の値は載せない。サンプル表記だけにしておく。

Gmail送信とBlogger操作の許可を受ける

アプリではGoogleの認可処理を使い、メール送信時にはGmail送信とBlogger操作のスコープを要求する。Bloggerの反映を確認するだけの場面では、Blogger側のスコープを要求している。実装で指定している文字列は次の2つで、個人のIDや秘密鍵ではない。

private const val BLOGGER_SCOPE =
    "https://www.googleapis.com/auth/blogger"
private const val GMAIL_SEND_SCOPE =
    "https://www.googleapis.com/auth/gmail.send"

今回のプロジェクトはGoogle Cloud側でGmail API・Blogger APIを有効にし、Android用OAuth設定を済ませて使う構成である。以下のaccessTokenには認可結果を渡す。APIキーだけを渡したり、アカウントのパスワードをコードへ書いたりする形ではない。

認可画面と結果の受け取り処理はここでは省略する。コードで使うのは、AndroidのUriBase64org.json.JSONObject、JavaのHTTP通信、KotlinのCoroutinesなど。自作クラス名と、ライブラリが提供するクラス名を分けて読むと追いやすい。

写真を渡してから、同じ下書きの本文を更新する

写真付き記事の処理は、次の順番で進む。

  • 記事データから、写真URLの差し込み位置を持つHTMLを作る。
  • 識別番号を付けた写真付きメールをGmail APIで送信する。
  • Blogger側に対応する画像付き下書きができるのを待つ。
  • 下書きからBloggerの画像URLを取得する。
  • 写真URLをHTMLへ差し込み、同じ下書きの記事タイトルと本文を更新する。
メールでできた記事とは別に、完成記事をもう1個作る?
いや、写真が入ったその下書きを更新する。
写真用の記事と、本文の記事が別々に残るわけじゃないんだ。
同じ下書きを、完成した記事の形へ置き換える。
写真の置き場所が決まってから、最後のピースを入れる感じね。

メールには識別番号を持たせている。単に新しい記事を探すだけでは、ほかの下書きと混ざる可能性があるためだ。自分が送った写真に対応する下書きを見つけてから更新へ進む。

写真URLは、あとから差し込む

第3回で組み立てたHTMLは、最初の時点ではBloggerの画像URLが分からない。そこで、写真の位置へ専用の目印を入れておく。次は実装の方式を短くした例である。

const val COVER_IMAGE_PLACEHOLDER = "__BOOKBLOGAI_COVER_IMAGE_URL__"

// 実際には、第3回の記事生成処理で作成した本文を使う。
val htmlTemplate = """
    <article class="book-review">
      <img src="$COVER_IMAGE_PLACEHOLDER" alt="本の写真">
      <h1>サンプル小説を読んで</h1>
      <p>ここに清書した感想が入ります。</p>
    </article>
""".trimIndent()

// 記事ごとに生成して、待機情報と一緒に保存する。
val uploadToken = "BOOKPHOTO-${java.util.UUID.randomUUID()}"

識別番号はメールの件名と本文に入れる。この番号が、後でBloggerの下書きを探すときの目印になる。本文を完成記事に更新した後は、識別用の仮タイトルも正式な記事タイトルへ置き換わる。

写真付きメールは、MIME形式で組み立てる

実装ではContentResolverで選択写真を読み取り、本文と写真を含むメールを作る。次はJPEG画像1枚に絞って短くしたサンプル関数。完成版には画像サイズの上限確認と、PNGなどの形式に応じた処理もある。

import android.util.Base64
import java.util.UUID

fun buildPhotoMail(
    to: String,
    uploadToken: String,
    jpegBytes: ByteArray
): ByteArray {
    require(to.isNotBlank() && '\r' !in to && '\n' !in to)
    require(Regex("[A-Za-z0-9-]+").matches(uploadToken))
    require(jpegBytes.isNotEmpty())
    val boundary = "photo-${UUID.randomUUID()}"
    val text = "写真付き記事を準備しています。\n識別番号: $uploadToken"
    val body64 = Base64.encodeToString(
        text.toByteArray(Charsets.UTF_8), Base64.CRLF
    ).trimEnd('\r', '\n')
    val image64 = Base64.encodeToString(
        jpegBytes, Base64.CRLF
    ).trimEnd('\r', '\n')

    val lines = listOf(
        "To: $to",
        "Subject: $uploadToken",
        "MIME-Version: 1.0",
        "Content-Type: multipart/mixed; boundary=\"$boundary\"",
        "",
        "--$boundary",
        "Content-Type: text/plain; charset=UTF-8",
        "Content-Transfer-Encoding: base64",
        "",
        body64,
        "--$boundary",
        "Content-Type: image/jpeg",
        "Content-Transfer-Encoding: base64",
        "Content-Disposition: attachment; filename=\"book-cover.jpg\"",
        "",
        image64,
        "--$boundary--",
        ""
    )
    return lines.joinToString("\r\n").toByteArray(Charsets.UTF_8)
}
写真を、そのままJSONに入れて送るの?
まずは写真を添付したメールを作る。それをGmail APIへ渡すので、メールの形式とAPIの形式の両方を扱っている。

メール全体をbase64urlにして、Gmail APIへ渡す

MIME内の添付画像には通常のBase64を使い、メール全体はbase64urlに変換してJSONのrawへ入れる。この送信形式はGmail APIの公式仕様に沿っている。Gmail API公式:メールの作成と送信

// buildPhotoMail()で作ったメール全体を変換する。
val mimeBytes = buildPhotoMail(postingEmailAddress, uploadToken, jpegBytes)
val raw = android.util.Base64.encodeToString(
    mimeBytes,
    android.util.Base64.URL_SAFE or
        android.util.Base64.NO_WRAP or
        android.util.Base64.NO_PADDING
)
val body = org.json.JSONObject().put("raw", raw)

// 次のrequestJson()をCoroutine内から呼び出す。
val response = requestJson(
    method = "POST",
    url = "https://gmail.googleapis.com/gmail/v1/users/me/messages/send",
    accessToken = accessToken,
    body = body
)
val messageId = response.optString("id")
check(messageId.isNotBlank()) { "メール送信結果にIDがありません。" }

このmessageIdはGmailのメールIDで、Bloggerの記事IDとは別である。両者を取り違えず、Blogger側では識別番号から下書きを探す。

HTTP通信の共通部分

APIとのやり取りを見通しよくするため、実装のHTTP処理を短い共通関数に整理した。完成版はGmail用・Blogger用それぞれで詳しいエラー表示を行っているが、この例ではHTTPステータスを確認して結果を返す。

import java.net.HttpURLConnection
import java.net.URL
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject

suspend fun requestJson(
    method: String,
    url: String,
    accessToken: String,
    body: JSONObject? = null
): JSONObject = withContext(Dispatchers.IO) {
    val connection = URL(url).openConnection() as HttpURLConnection
    try {
        connection.requestMethod = method
        connection.connectTimeout = 15_000
        connection.readTimeout = 60_000
        connection.setRequestProperty("Authorization", "Bearer $accessToken")
        connection.setRequestProperty("Accept", "application/json")
        if (body != null) {
            connection.doOutput = true
            connection.setRequestProperty(
                "Content-Type", "application/json; charset=UTF-8"
            )
            val bytes = body.toString().toByteArray(Charsets.UTF_8)
            connection.setFixedLengthStreamingMode(bytes.size)
            connection.outputStream.use { it.write(bytes) }
        }
        val status = connection.responseCode
        check(status in 200..299) { "API通信に失敗しました: HTTP $status" }
        val text = connection.inputStream.bufferedReader(Charsets.UTF_8)
            .use { it.readText() }
        if (text.isBlank()) JSONObject() else JSONObject(text)
    } finally {
        connection.disconnect()
    }
}

通信はDispatchers.IOで実行する。これを呼ぶ画面側はCoroutineを使い、処理中表示やボタン制御を担当する。通信の待ち時間を、そのまま画面操作を止める時間にしない構成である。

メール送信成功と、記事完成は別の状態

この連携で必要になったのが、待機中の表示と確認処理だった。メールを送れた瞬間に、Blogger側の画像付き下書きまで使えるとは限らない。

送信できたなら完了じゃないの?
メールを送れたことは分かる。でも、Bloggerが写真を取り込んだかは次に確認する必要がある。
その間、画面は?
下書きの反映待ちだと表示する。自動確認に加えて、手動で今すぐ確認する操作も用意した。
押すたびに写真を再送したりしないでしょうね。
送信済みの処理は、その続きの確認へ進める。

下書きがまだなければ待機を続ける。下書きがあっても写真URLを取得できなければ、記事完成にはしない。メール送信、下書き確認、本文更新という段階を、それぞれの状態として扱うようにした。

画面上でも、書誌情報を確認しているのか、AIが清書しているのか、写真を送っているのか、Bloggerを待っているのかが分かる。待ち時間がある処理ほど、今どこまで進んだのかという表示が効いてくる。

対応する下書きを探す

完成版では、認証したGoogleアカウントのブログ一覧を取得し、設定したブログURLに一致するブログのIDを使う。そのブログの下書きを取得して、件名または本文に識別番号が含まれるものを探す。

以下はBloggerApiClient内の実装の抜粋。apiUrl()はBlogger APIのURLを作る自作メソッド、requestJson()は操作名付きのクラス内HTTPメソッドである。上の説明用共通関数とは引数が異なる。

suspend fun findDraftByUploadToken(
        accessToken: String,
        blogId: String,
        uploadToken: String
    ): DraftPost? = withContext(Dispatchers.IO) {
        val url = apiUrl("blogs/$blogId/posts")
            .appendQueryParameter("status", "draft")
            .appendQueryParameter("fetchBodies", "true")
            .appendQueryParameter("orderBy", "updated")
            .appendQueryParameter("maxResults", "50")
            .appendQueryParameter("view", "ADMIN")
            .appendQueryParameter("fields", "items(id,title,content)")
            .build()
            .toString()
        val response = requestJson("下書き一覧取得", "GET", url, accessToken)
        val items = response.optJSONArray("items") ?: return@withContext null

        for (index in 0 until items.length()) {
            val item = items.optJSONObject(index) ?: continue
            val title = item.optString("title")
            val content = item.optString("content")
            if (title.contains(uploadToken) || content.contains(uploadToken)) {
                return@withContext DraftPost(
                    id = item.getString("id"),
                    title = title,
                    content = content
                )
            }
        }
        null
    }

DraftPostは次の小さなデータ型で、同じクラス内に定義している。

data class DraftPost(
    val id: String,
    val title: String,
    val content: String
)

今回の実装は更新順の下書きを最大50件取得し、その範囲で照合する。全ページを巡回する実装ではない。新しく送った写真の下書きを探す個人用の処理として、この範囲で組み立てた。

写真URLを取り出して、記事へ差し込む

下書きのHTMLから写真のURLを取り出す。完成版の抽出処理は、srchrefから候補を集め、HTTPSか、画像のホスト名やサイズ表記はどうかを調べて選ぶ。返されるHTMLには小さな表示用画像とリンク先画像があるため、単に最初の文字列を使う処理にはしていない。

写真が入った下書きができたら、画像そのものをまたアップロードする?
その必要はない。Bloggerが用意した写真URLを、最初に作っておいたHTMLへ入れる。

差し込み部分だけなら、次の関数になる。写真URLはHTML属性の値としてエスケープしておく。

import android.text.TextUtils

fun insertCoverImageUrl(htmlTemplate: String, imageUrl: String): String {
    val marker = "__BOOKBLOGAI_COVER_IMAGE_URL__"
    require(htmlTemplate.contains(marker)) { "写真の差し込み位置がありません。" }
    return htmlTemplate.replace(marker, TextUtils.htmlEncode(imageUrl.trim()))
}

同じ記事IDへPUTし、タイトルと本文を更新する

写真付き下書きの記事IDを指定して、完成HTMLへ更新する。新規記事を追加する呼び出しではなく、既存記事の更新である。Blogger API公式:Posts.update

HTTP部分を上の共通関数に合わせて整理すると、次のようになる。blogIddraftIdはAPIの取得結果を使う。個人の記事IDをコードに固定する必要はない。

// Coroutine内の処理。タイトルとHTMLは第3回の生成結果を使う。
val updateUrl = android.net.Uri.Builder()
    .scheme("https")
    .authority("www.googleapis.com")
    .appendPath("blogger").appendPath("v3")
    .appendPath("blogs").appendPath(blogId)
    .appendPath("posts").appendPath(draftId)
    .appendQueryParameter("fetchBody", "true")
    .build().toString()

val updated = requestJson(
    method = "PUT",
    url = updateUrl,
    accessToken = accessToken,
    body = org.json.JSONObject()
        .put("id", draftId)
        .put("title", finalTitle)
        .put("content", finalHtml)
)
val updatedContent = updated.optString("content")
check(updatedContent.contains("book-review")) { "記事本文を確認できません。" }
check(updatedContent.contains(imageUrl)) { "写真URLを確認できません。" }

これは今回の実装に合わせた簡易な更新結果の確認である。HTML全体の同一性や、画像が実際に表示できることまで検証するものではない。最終的な内容と表示はBloggerの管理画面で確認する。

全体をつなぐ処理は、この順番になる

細かいHTTP処理をクラスへ分けると、画面側から呼ぶ1回分の処理は次のようになる。これはMainActivity内の実際の抜粋で、設定ストアやAPIクライアントはActivityのメンバーとして用意している。

private suspend fun processPendingDraftOnce(
        accessToken: String,
        pendingPost: BloggerPendingPostStore.PendingPost
    ): BloggerApiClient.PreparedDraft? {
        val settings = bloggerSettingsStore.load()
        val blog = bloggerApiClient.findBlogByConfiguredUrl(
            accessToken = accessToken,
            configuredBlogUrl = settings.blogUrl
        )
        val draft = bloggerApiClient.findDraftByUploadToken(
            accessToken = accessToken,
            blogId = blog.id,
            uploadToken = pendingPost.uploadToken
        ) ?: return null
        val imageUrl = bloggerApiClient.extractBloggerImageUrl(draft.content)
            ?: throw IllegalStateException(
                "下書きは見つかりましたが、Bloggerの画像URLを取得できませんでした。添付画像が本文に表示されているか確認してください。"
            )
        val finalHtml = BloggerEmailPostComposer.insertCoverImageUrl(
            pendingPost.htmlTemplate,
            imageUrl
        )
        return bloggerApiClient.replaceDraftWithArticle(
            accessToken = accessToken,
            blogId = blog.id,
            draft = draft,
            finalTitle = pendingPost.finalTitle,
            finalHtml = finalHtml,
            imageUrl = imageUrl
        )
    }

下書きがなければnullを返す。見つかったのに画像URLが取れなければエラー。本文更新まで確認できればPreparedDraftを返す。この戻り値によって、待つのか、問題を表示するのか、完成として記録するのかを画面側で分ける。

途中で止めても、何を待っていたか分かるようにする

通信が絡むと、毎回一度で最後まで進めるとは限らない。処理待ちの情報を端末へ保存し、中断や再開の操作も加えた。画面を戻ったあとでも、残っている処理を確認できるようにしている。

写真を送ったあとに、ずっと画面を見て待つのは面倒なんだけど。
待機情報を残して、あとから確認や再開ができるようにした。
もうやめたい場合は?
処理を終了する。ただし、送信済みのメールやBlogger側の下書きまで消す操作ではない。
端末側で待つのをやめる話と、送信先を消す話は別か。

この区別は、後で追加した履歴削除にもつながる。端末内の記録を整理する操作が、そのままブログ側の記事削除になると扱いづらいからだ。

自動確認は回数を決め、続きの位置を保存する

今回の自動確認は、送信から10秒、30秒、1分、5分の時点で行う構成にした。これはアプリで決めた確認間隔であり、Bloggerがその時間以内に処理を終えるという意味ではない。

private val BLOGGER_AUTOMATIC_CHECK_OFFSETS_MILLIS =
    longArrayOf(10_000L, 30_000L, 60_000L, 300_000L)

待機情報には、識別番号、仮のHTML、最終タイトル、写真URI、処理状態、送信時刻、次の確認位置などを保存する。再開時は送信済みかを確認して、送信または下書き確認の段階へ進む。5分後の確認でも見つからなければ、手動確認を案内する。

アプリを閉じても、ずっと裏で確認し続ける?
今回の実装はActivityのCoroutineで確認するものだ。常駐処理の保証ではなく、保存した状態を使ってあとから確認・再開できるようにしている。

また、送信済み状態が保存できた場合は再送を避けられるが、通信が途切れて送信結果が分からなくなった場合まで、重複を完全に防げる仕組みではない。送信の成功を取り逃した可能性があるときは、送信先の状態も確認する。端末の状態管理と、通信先で実際に起きたことは分けて扱う必要がある。

ゴールは「公開」ではなく「下書きの完成」

今回のアプリは、完成した記事をBloggerの下書きとして用意するところをゴールにした。メール投稿側も下書きとして受け取る設定で使う。アプリ側の成功表示も、Blogger管理画面で内容を確認する案内になっている。

できた記事はそのまま公開?
下書きまで。内容を確認してから公開する。
AIのあらすじや細かい評価も、そこで見るわけね。
自分の感想が変わっていないか、本の情報が合っているか、写真が入っているかも確認できる。
最後に確認できるなら、その方が使いやすい。

入力するのは自分の感想。Gemmaが文章を整え、アプリがHTMLを組み立て、写真はメール経由でBloggerへ渡す。最後にAPIで同じ下書きを更新する。内部ではいくつかの処理をつないでいるが、手で写真を貼り直したり、文章をコピーして戻ったりする作業は減らせる。

投稿できたら、今度は記録を残したくなった

写真と記事本文がBloggerの下書きとして揃った。次は、その作成内容をアプリ側でも振り返れるようにする。

これで写真付きの下書きまで作れるようになった。
ようやく、最初に欲しかった動きになったな。
で、前に何を書いたかも見たい。
次は履歴か。
せっかくアプリなんだから、そこまで欲しいでしょ。

第5回は、過去履歴とダッシュボード。記事を作って送るだけだったアプリに、読み返す機能と整理する機能を加えていく。

この記事について

この記事は実際の開発内容をもとに、説明の順序を整理し、登場人物の会話として再構成しています。構成・文章・会話の作成にはOpenAIのChatGPTを使用しました。サンプルコードは実装から抜粋・整理し、個人の設定値はサンプル表記に置き換えています。アプリ内の文章生成には、端末内で実行するGemmaを使用しています。

検索

年月ごとアーカイブ

フレンドブログ

Translate

自己紹介

自分の写真
映画が好き! 音楽好き! 演奏も好き! ミキシングが一番好き!

QooQ