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

2026年9月15日火曜日

Androidアプリ プログラミング

t f B! P L

読書ブログAI開発記・第5回|履歴とダッシュボードを整え、普段使えるアプリへ

前回までで、写真付きの読書記事をBloggerの下書きとして完成させる流れがつながった。今回は普段使うための仕上げ。過去に入力した感想、AIが整えた文章、完成記事を端末から振り返り、不要な記録を削除できるようにした。
今回のサンプルについて
第4回までのアプリを土台に、実際の履歴保存・集計・削除処理を抜粋しています。一部は説明のため短く整理しました。単独でアプリ全体になるコードではありません。

LocalPostHistoryDatabasePostHistoryContentBloggerPendingPostStoreは今回の自作クラスです。個人のブログURL、記事ID、パッケージ名、端末内パスは掲載せず、入力例には架空の値を使っています。

投稿できるだけでは、あとから振り返れない

Bloggerへ下書きを送れるようになると、今度はアプリ側にも記録が欲しくなった。完成した記事だけならBloggerで読めるが、最初にどんな感想を入力したのか、AIがどのように整えたのかも確認したい。

過去履歴って、タイトルが並ぶだけ?
それだけでも投稿した本は分かる。
足りない。最初に書いた感想と、AIが直した文章も見たい。
じゃあ、入力内容とAIの生成内容を分けて残そう。
そう。それなら後から見ても分かる。

保存先には端末内のSQLiteを使う。単なる投稿済み一覧ではなく、入力から完成までを振り返るための履歴にした。

保存済み履歴と、処理待ちデータを分ける

端末内のDBには、過去の記録をためるpost_historyと、投稿処理の続きを保持するpending_postを用意した。後者は、写真送信後の反映待ちや、一時停止した処理を再開するために使う。

テーブル役割今回の保持方法
post_history入力・AI生成・完成HTMLなどの履歴履歴ごとに1行追加
pending_post送信・確認を再開するための状態進行中の処理1件を保持

DBはSQLiteOpenHelperを継承した自作クラスで扱っている。今回のソースではDBバージョンは3。起動時のテーブル作成や、古いDBへの列追加もこのクラスの役割にした。

import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper

// クラス宣言と初期作成部分の抜粋。各テーブル作成メソッドは同クラス内に定義。
class LocalPostHistoryDatabase(context: Context) : SQLiteOpenHelper(
    context.applicationContext,
    "book_blog_ai_local.db",
    null,
    3
) {
    override fun onCreate(database: SQLiteDatabase) {
        createPostHistoryTable(database)
        createPendingPostTable(database)
    }

    // createPostHistoryTable / createPendingPostTable / onUpgradeなどは省略。
}
未送信のデータまで、全部同じ履歴に並べるんじゃないの?
画面では両方を見せるけど、保存するテーブルは分けた。あとで履歴を整理するとき、作業中の投稿まで巻き込まずに済む。

元の入力と、AIが作った内容を両方保存する

履歴には、自分が入力したタイトル、著者名、総合評価、個人的な感想を保存する。さらに、AIが補正した書名や著者名、出版社、発売日、あらすじ、清書した感想、総評なども残す。

完成した記事のHTML、処理状態、日時、Blogger記事IDも記録する。これにより、何を入力し、どのような記事になったのかを一つの履歴から確認できる。

書名が正式表記に変わったら、最初の入力は消えない?
元の入力も別に持っている。
感想も?
入力した感想と、清書した感想の両方が残る。
それなら「こんなこと書いたっけ?」と思ったときに見直せる。

詳細画面では、入力内容、AIによる補正・清書、完成記事の文字表示、Blogger情報を確認できる。完成記事の文字表示は、内容を読むためのもの。Blogger上のレイアウトそのものを再現するプレビューとは役割が違う。

本人の入力を、JSONへまとめて保存する

入力内容には専用のデータ型を用意した。次はPostHistoryContent内の定義と変換処理の抜粋。SQLiteにはJSON文字列として保存し、詳細画面を開くときに読み戻す。

data class EnteredData(
        val title: String,
        val author: String,
        val overallRating: Int,
        val personalImpression: String
    )

fun encodeEntered(data: EnteredData): String = JSONObject()
        .put("title", data.title)
        .put("author", data.author)
        .put("overallRating", data.overallRating.coerceIn(1, 5))
        .put("personalImpression", data.personalImpression)
        .toString()

    fun decodeEntered(value: String): EnteredData? {
        if (value.isBlank()) return null
        return runCatching {
            val json = JSONObject(value)
            EnteredData(
                title = json.optString("title"),
                author = json.optString("author"),
                overallRating = json.optInt("overallRating", 0),
                personalImpression = json.optString("personalImpression")
            )
        }.getOrNull()
    }

呼び出し方は次の通り。書名と著者名は説明用の架空の値である。

val entered = PostHistoryContent.EnteredData(
    title = "サンプル小説",
    author = "サンプル著者",
    overallRating = 4,
    personalImpression = "前半は少し読みにくかったが、後半は続きが気になった。"
)
val enteredJson = PostHistoryContent.encodeEntered(entered)

// generatedArticleは第3回のAI生成結果。
val aiJson = PostHistoryContent.encodeAi(generatedArticle)

AI側も同様に、正式タイトル、著者名、あらすじ、清書した感想、良かった点・気になった点、総評、おすすめ対象、詳細評価をJSONへまとめる。本人の入力と同じ文字列に上書きしないところが大切だ。

履歴の主要な列

実装の保存先を抜き出すと、次のようになる。日時はミリ秒単位の整数、写真の有無は0または1で持つ。

列名保存するもの
book_title履歴一覧に表示するタイトル
entered_data_json本人が入力した書名・著者・評価・感想
ai_data_jsonAIによる補正・清書内容
generated_article完成記事のHTML
blogger_post_idBlogger側の記事ID
publish_status下書き完成・中断終了などの状態
created_at / updated_at / completed_at作成・更新・完成の日時
has_photo写真あり/なし

下書きが完成したときは、第4回の待機情報から保存内容を作る。次は完成時処理の中心部分を抜粋したもの。pendingPostは保存中の待機情報、preparedDraftはBloggerの更新成功結果である。

postHistoryDatabase.recordArticleState(
    LocalPostHistoryDatabase.ArticleState(
        title = pendingPost.finalTitle,
        generatedArticle = BloggerEmailPostComposer.insertCoverImageUrl(
            pendingPost.htmlTemplate,
            preparedDraft.imageUrl
        ),
        bloggerPostId = preparedDraft.postId,
        status = LocalPostHistoryDatabase.STATUS_DRAFT_READY,
        createdAtMillis = pendingPost.createdAtMillis,
        enteredDataJson = pendingPost.enteredDataJson,
        aiDataJson = pendingPost.aiDataJson,
        hasPhoto = pendingPost.hasPhoto,
        completedAtMillis = System.currentTimeMillis()
    )
)

実際の呼び出し元では保存の成功・失敗も判定する。Bloggerの下書きが完成したことと、端末への履歴保存が成功したことは別であり、履歴保存に失敗した場合はその旨を表示する。

完成記事は文字として読み返す

履歴詳細では入力とAI生成内容をそれぞれ表示し、完成HTMLも読みやすい文字列へ変換する。実装では、画像タグを除いてからHtml.fromHtml()へ渡している。

// 必要なimport: android.text.Html
private fun articleHtmlToReadableText(html: String): String {
        if (html.isBlank()) return ""
        val withoutImages = html.replace(Regex("(?is)<img\\b[^>]*>"), "")
        return Html.fromHtml(withoutImages, Html.FROM_HTML_MODE_COMPACT)
            .toString()
            .replace(Regex("[ \\t]+\\n"), "\n")
            .replace(Regex("\\n{3,}"), "\n\n")
            .trim()
    }

これで、写真の読み込みを待たずに本文を振り返れる。ブログ上の見た目の確認はBlogger側で行い、履歴画面では何を書いたかを読むことに集中できる。

写真は履歴にため込まない

写真については、完成履歴に画像データや端末内の写真URIを保存しない方針にした。記録するのは「写真あり/なし」である。

本の写真も履歴へ保存する?
写真本体は保存しない。写真付きだったことだけ残す。
記事の文章は全部残すのに?
履歴で振り返りたいのは、入力と記事の内容だからな。写真まで重ねて持つ構成にはしなかった。

一方、送信や再開の処理中には、使う写真を参照するために一時的なURIが必要になる。その参照は処理中の情報として扱い、完了や処理の終了時に片付ける。元の写真ファイルを削除するという意味ではない。

また、記事HTMLに含まれるBlogger側の画像URLと、スマホ内の写真URIは別物だ。完成したHTMLは残るが、それは端末の写真データを履歴DBへ取り込むことではない。

画像URIを残さないことも、保存処理で明示する

互換性のためDBには旧来のbook_image_uri列が残っているが、新しい履歴を保存するときはNULLを入れる。実装のContentValuesから、該当部分を抜粋するとこうなる。

// recordArticleState()内のContentValues().apply { ... }から抜粋。
// COLUMN_BOOK_IMAGE_URI = "book_image_uri"
// COLUMN_HAS_PHOTO = "has_photo"
putNull(COLUMN_BOOK_IMAGE_URI)
put(COLUMN_HAS_PHOTO, if (article.hasPhoto) 1 else 0)

以前のDBをバージョン3へ更新するときも、保存済み写真URIから「写真あり」を引き継いだあと、URIを消す。次は、実装の更新SQLを列名が見える形にした抜粋である。

UPDATE post_history
SET has_photo = CASE
    WHEN book_image_uri IS NOT NULL
     AND TRIM(book_image_uri) <> '' THEN 1
    ELSE has_photo
END;

UPDATE post_history SET book_image_uri = NULL;

入力内容やAI生成内容を保存する機能がなかった時代の履歴には、後から元データを作り足せない。そのため、未記録の項目は「機能追加前のため未記録」と表示するようにした。

ダッシュボードの件数は何を数えるのか

ホーム画面には、直近の記事、今月の件数、先月の件数を表示する。ここで基準にしたのは、Bloggerの下書きが完成したことだった。

今月の投稿数って、公開した記事の数?
このアプリでは、下書きが完成した件数だ。
メールを送っただけのものは?
まだ完成していない。本文の更新まで終わったものを実績として扱う。
なら、待っている記事が件数に混ざらないのね。

この集計は端末内に保存した履歴に基づく。Bloggerで公開した日時や、ブログ全体の記事数を同期して集計する機能ではない。アプリでどのくらい読書記事を作ったかを見るための表示である。

月初から翌月初までで、完成件数を数える

今月と先月の境界は端末のタイムゾーンを使って計算し、完成日時で集計する。今回のdashboardSummary()は次の実装になっている。DashboardSummaryは直近記事と2か月分の件数をまとめる自作データ型である。

// 必要なimport: java.time.ZonedDateTime
fun dashboardSummary(now: ZonedDateTime = ZonedDateTime.now()): DashboardSummary {
        val thisMonthStart = now
            .withDayOfMonth(1)
            .toLocalDate()
            .atStartOfDay(now.zone)
        val nextMonthStart = thisMonthStart.plusMonths(1)
        val lastMonthStart = thisMonthStart.minusMonths(1)

        val database = readableDatabase
        return DashboardSummary(
            latestPost = queryLatestPost(database),
            thisMonthCount = queryCompletedCount(
                database,
                thisMonthStart.toInstant().toEpochMilli(),
                nextMonthStart.toInstant().toEpochMilli()
            ),
            lastMonthCount = queryCompletedCount(
                database,
                lastMonthStart.toInstant().toEpochMilli(),
                thisMonthStart.toInstant().toEpochMilli()
            )
        )
    }

実際に件数を数えるSQLは、次の処理。COLUMN_...TABLE_...は、上の表にある列名・テーブル名を保持するクラス内の定数である。

private fun queryCompletedCount(
        database: SQLiteDatabase,
        startMillis: Long,
        endMillisExclusive: Long
    ): Int {
        database.rawQuery(
            """
            SELECT COUNT(*)
            FROM $TABLE_POST_HISTORY
            WHERE $COLUMN_PUBLISH_STATUS IN (?, ?)
              AND $COLUMN_COMPLETED_AT >= ?
              AND $COLUMN_COMPLETED_AT < ?
            """.trimIndent(),
            arrayOf(
                STATUS_DRAFT_READY,
                STATUS_PUBLISHED,
                startMillis.toString(),
                endMillisExclusive.toString()
            )
        ).use { cursor ->
            return if (cursor.moveToFirst()) cursor.getInt(0) else 0
        }
    }

期間条件は「月初以上、翌月初未満」。例えば9月分なら、9月1日0時から10月1日0時の直前までを扱う。月末の最終秒を手で作らず、隣り合う月の境界で分ける。

SQLには、状態が2つ入っているけど?
今回作る下書き完成のdraft_readyに加えて、既存データのpublishedも集計対象にしている。どちらもcompleted_atが期間内のものを数える。

publishedも対象にしているのは既存状態との互換性のためで、Bloggerの公開状態を自動同期しているという意味ではない。古い完成履歴では、DB更新時に既存の公開日時または更新日時から完成日時を補っている。

0件と、読み込み失敗は表示を分ける

集計が正常に終わって履歴がなければ「まだ投稿はありません」。DBが読めなかった場合は「履歴を読み込めません」と表示し、件数も0件ではなく「-」にする。

private fun refreshDashboardSummary() {
        runCatching {
            postHistoryDatabase.dashboardSummary()
        }.onSuccess { summary ->
            val latestPost = summary.latestPost
            if (latestPost == null) {
                textDashboardLatestPostDate.text = "まだ投稿はありません"
                textDashboardLatestPostTitle.text =
                    "「投稿画面を開く」から最初の記事を作成できます。"
            } else {
                val formattedDate = Instant.ofEpochMilli(latestPost.completedAtMillis)
                    .atZone(ZoneId.systemDefault())
                    .format(DASHBOARD_DATE_FORMATTER)
                textDashboardLatestPostDate.text = formattedDate
                textDashboardLatestPostTitle.text = latestPost.title
            }

            textDashboardThisMonthCount.text = "${summary.thisMonthCount}件"
            textDashboardLastMonthCount.text = "${summary.lastMonthCount}件"
        }.onFailure {
            textDashboardLatestPostDate.text = "履歴を読み込めません"
            textDashboardLatestPostTitle.text =
                "端末内の投稿履歴データベースを確認できませんでした。"
            textDashboardThisMonthCount.text = "-"
            textDashboardLastMonthCount.text = "-"
        }
    }

上の表示処理はActivity内の抜粋で、TextViewや日付フォーマッターは初期化済みのメンバーを使う。InstantZoneIdjava.timeのクラスである。エラーを単なる0件として扱わないことで、本当に履歴がないのか、読み取れなかったのかを区別できる。

最後に欲しくなったのは、削除する操作だった

履歴を残せるようになったところで、不要なテスト履歴を消したくなった。まず詳細画面に1件削除を加え、さらに一覧カードの長押しでも削除できるようにした。

詳細を開けば消せるのは分かった。
それで十分か?
一覧で長押しして消したい。毎回開くのは面倒。
追加しよう。
あと、全部まとめて削除するボタンも。
最後の仕上げが、少しずつ増えるな。

最終的には、詳細画面からの個別削除、一覧の長押し削除、保存済み履歴の一括削除を用意した。削除前には確認画面を出す。

削除後は履歴一覧とダッシュボードの集計を更新する。端末内の履歴を数えているので、その履歴を削除すればダッシュボードの件数も変わる。

長押しを受けたら、確認ダイアログへ進む

カードをタップすると詳細表示、長押しすると削除確認へ進む。カードに付ける処理を短く書くと次のようになる。cardは表示中のカード、entryはその履歴のデータである。

card.setOnClickListener {
    showSavedHistoryDetail(entry.id)
}
card.setOnLongClickListener {
    confirmDeleteSavedHistory(entry.id, entry.title)
    true
}

長押しリスナーは処理を受け付けたことを示すtrueを返す。押した瞬間に削除するのではなく、対象のタイトルを確認してから削除する。

private fun confirmDeleteSavedHistory(id: Long, title: String) {
        MaterialAlertDialogBuilder(this)
            .setTitle("この履歴を削除しますか?")
            .setMessage(
                "「$title」を端末内の過去履歴から削除します。" +
                    "Blogger上の記事や下書きは削除されません。"
            )
            .setNegativeButton("戻る", null)
            .setPositiveButton("削除する") { _, _ ->
                val deleted = runCatching {
                    postHistoryDatabase.deleteHistoryEntry(id)
                }.getOrDefault(false)
                if (deleted) {
                    refreshHistory()
                    refreshDashboardSummary()
                    Toast.makeText(
                        this,
                        "履歴を削除しました。",
                        Toast.LENGTH_SHORT
                    ).show()
                } else {
                    Toast.makeText(
                        this,
                        "履歴を削除できませんでした。",
                        Toast.LENGTH_SHORT
                    ).show()
                }
            }
            .show()
    }

MaterialAlertDialogBuilderはMaterial Componentsのダイアログ、ToastはAndroidの短い通知表示である。今回のアプリでは既に使用している部品を使った。refreshHistory()refreshDashboardSummary()は自作の画面更新処理で、削除成功後に両方を呼ぶ。

DBの削除は、履歴IDで1件を指定する

確認後に呼ばれるDB側の処理は短い。タイトルではなく、端末内で付けた履歴IDを条件にするので、同じ本の履歴が複数あっても対象を分けられる。

fun deleteHistoryEntry(id: Long): Boolean {
        if (id <= 0L) return false
        return writableDatabase.delete(
            TABLE_POST_HISTORY,
            "$COLUMN_ID = ?",
            arrayOf(id.toString())
        ) > 0
    }

このidはSQLiteのpost_historyの主キーで、Bloggerの記事IDとは別である。削除処理は端末内DBに対して行っており、Bloggerの記事削除APIは呼んでいない。

待機中の処理と、保存済み履歴では消し方を分ける

保存済みの履歴は端末内の記録を削除する。待機中の履歴を個別に削除する場合は、自動確認も止めて、その処理の待機情報を片付ける。

一括削除の対象は保存済み履歴であり、待機中の処理は対象に含めない。いずれの操作でも、Blogger側の記事や下書き、送信済みメールは削除しない。

「履歴をすべて削除」でブログまで空になったりしない?
ならない。削除するのは端末内の保存済み履歴だ。
待機中の投稿も、一括では消えない?
そこは残す。止めたい処理は個別に確認して削除する。
それなら、テスト履歴をまとめて片付けやすい。

一括削除の対象を、テーブルで限定する

一括削除はpost_historyだけを対象にする。下書き完成以外に「中断終了」として保存した履歴があれば、それも削除対象になる。一方、pending_postに保持している進行中・一時停止中のデータは残る。

// LocalPostHistoryDatabase内。TABLE_POST_HISTORY = "post_history"
fun clearHistoryEntries(): Int =
    writableDatabase.delete(TABLE_POST_HISTORY, null, null)

条件を付けずに履歴テーブルの全行を削除し、削除件数を返す。確認画面では、消える範囲と残る範囲を説明する。

private fun confirmDeleteAllSavedHistory() {
        MaterialAlertDialogBuilder(this)
            .setTitle("保存済み履歴をすべて削除しますか?")
            .setMessage(
                "端末内の保存済み履歴をすべて削除します。" +
                    "処理中・中断中の投稿データと、Blogger上の記事や下書きは" +
                    "削除されません。この操作は元に戻せません。"
            )
            .setNegativeButton("戻る", null)
            .setPositiveButton("すべて削除する") { _, _ ->
                val deletedCount = runCatching {
                    postHistoryDatabase.clearHistoryEntries()
                }.getOrElse { -1 }
                if (deletedCount >= 0) {
                    refreshHistory()
                    refreshDashboardSummary()
                    Toast.makeText(
                        this,
                        "保存済み履歴を${deletedCount}件削除しました。",
                        Toast.LENGTH_SHORT
                    ).show()
                } else {
                    Toast.makeText(
                        this,
                        "保存済み履歴を削除できませんでした。",
                        Toast.LENGTH_SHORT
                    ).show()
                }
            }
            .show()
    }

一覧を更新するときは、保存済み履歴があるかどうかで一括削除ボタンの有効状態も更新する。

// refreshHistory()内。historyEntriesは読み込んだ保存済み履歴。
buttonHistoryClearAll.isEnabled = historyEntries.isNotEmpty()

待機中の個別削除は、確認処理も止める

待機中のカードも長押しで個別削除できる。その場合はDBの行を消すだけでなく、自動確認やAPI処理のJobへキャンセルを要求し、待機情報を消して画面を更新する。次は実装の抜粋である。

private fun deletePendingHistory() {
        bloggerAutomaticCheckJob?.cancel()
        bloggerAutomaticCheckJob = null
        bloggerApiJob?.cancel()
        bloggerApiJob = null
        bloggerAuthorizationAction = null
        val deleted = runCatching {
            bloggerPendingPostStore.clear()
        }.getOrDefault(false)
        setBloggerApiBusy(false)
        refreshBloggerPostControls()
        refreshHistory()
        refreshDashboardSummary()
        Toast.makeText(
            this,
            if (deleted) {
                "待機中の履歴を削除し、自動確認を停止しました。"
            } else {
                "待機中の履歴を削除できませんでした。"
            },
            Toast.LENGTH_SHORT
        ).show()
    }

これはアプリ内の処理を止めるための操作で、送信済みメールを取り消したり、Blogger側で受け付け済みの処理を巻き戻したりするものではない。キャンセル時点ですでに送信先へ渡ったものは、送信先に残る。

設定のテスト用データも片付けて、機能は完成

最後に、設定や入力画面のテスト用既定値も整理した。新規インストール時は空欄から設定し、端末にすでに保存している自分の設定は維持する。

これで完成。
記事生成、Blogger連携、履歴、削除まで揃ったな。
じゃあ、スマホに入れられるAPKを残しておきたい。
次はアプリをファイルにまとめる作業だな。
ビルドするだけでしょ?

機能としては、ここで一区切り。ただし、このアプリには第2回から引き継いだTensor用の追加モジュールがある。第6回は、それも含めて1個のAPKへまとめ、自分用のインストールファイルを作る話になる。

この記事について

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

検索

年月ごとアーカイブ

フレンドブログ

Translate

自己紹介

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

QooQ