hishidaの開発blog

EBシリーズ(EBPocket,EBWin,EBMac,EBStudio),KWIC Finder,xdoc2txt,読書尚友の開発者ブログ

読書尚友 for iOS の検討を開始

Android 版の読書尚友が落ち着いてきたので、次のお題としてiOS版の読書尚友を考えている。
iOS版の青空文庫ビューアはi文庫Sという老舗アプリがあり、他にもフリーのアプリが一杯あるので、2021年の段階で参入するのは今更感が拭えない。だが青空文庫の注記に完全対応したアプリは意外に少ないので、多少のニーズはあると思っている。

盛り込みたい内容は次ぐらいを考えている:

  • なるべく青空文庫の注記に完全対応する
  • 組版ルールをもう少し研究して紙の書籍の読書体験に近づける
  • 縦横の多段組に対応する
  • テキスト・PDFに対応する(ePubApple謹製のブックアプリがあるので、対応の必要性が薄い)
  • 青空文庫テキストの一括ダウンロードをサポート
  • ページめくりアニメーション(iOSだと簡単だがAndroidだと非常に難しい)

最近マルチプラットフォームの開発環境として Flutter が話題であり、AndroidiOS、さらにデスクトップのWindowsMacまでサポートできるメリットは大きい。Xamarinも後継の .Net MAUI で同様のことができるようになるらしい。
だが現時点ではSwiftでiOSネイティブアプリを開発する方が無難かと思っている。

EBPocketはObjective-Cで開発していたので、Swiftを使うのは初めてである。言語の勉強から始めているので遅々として進まないが、年内のリリースを目処にしたい。

読書尚友の対象範囲別ストレージ対応について

Qiitaに上げた方がいいような技術的な内容だが、忘備録として書いておく。

Googleから警告がきた

先月から、読書尚友とEBPocet for AndroidGoogle Playコンソールに、次のようなメッセージが表示されるようになった。

5月5日より、アプリがストレージへの広範なアクセスを必要とする理由をお知らせいただく必要があります

お客様のアプリで 1 件以上の App Bundle または APK のマニフェスト ファイルに requestLegacyExternalStorage フラグが含まれていることが検出されました。

Android 11 以降が搭載されているデバイスをアプリの対象とするデベロッパーは、ユーザーがデバイス ストレージへのアクセスを適切に制御できるよう対象範囲別ストレージを使用する必要があります。5 月 5 日以降に Android 11 以降でアプリをリリースするには、次のいずれかを行う必要があります。

・Storage Access Framework API、Media Store API など、よりプライバシーに配慮したベスト プラクティスを使用するようアプリを更新する
マニフェスト ファイルですべてのファイルへのアクセス(MANAGE_EXTERNAL_STORAGE)権限を申告するようアプリを更新し、5 月 5 日以降に Google Play Console ですべてのファイルへのアクセス権限の申告を完了する
・すべてのファイルへのアクセス権限をアプリから完全に削除する

Android 11 を対象とするアプリでは、requestLegacyExternalStorage フラグは無視されます。広範なアクセスを保持するには、すべてのファイルへのアクセス権限を使用する必要があります。

許可された用途なく、すべてのファイルへのアクセス権限へのアクセスをリクエストするアプリは、Google Play から削除されます。アップデートを公開することはできません。


どういうことかというと、Android 10(APIレベル29)以降、対象範囲別ストレージが導入され、ローカルストレージへのアクセスが厳格化された。アプリがアクセスできるのは、基本的にアプリ固有領域と呼ばれるサンドボックスだけで、共有ストレージにアクセスするためには、java.io.Fileクラスに代わり、Storage Access Frameworkを使用してアクセスする必要がある。これにはプログラムの大幅な修正が必要になる。

そこで対象範囲別ストレージに対応するまでの時間かせぎとして、requestLegacyExternalStorage フラグが設けられており、これをtrueにすれば、APIレベル29を対象とするアプリでも、従来と同様にローカルストレージに自由にアクセスができる。だがAPIレベル30以降を対象にしたアプリでは、requestLegacyExternalStorage フラグは無視されるので、いずれは対象範囲別ストレージへの対応は避けられない。

もう一つの方法は、MANAGE_EXTERNAL_STORAGE権限を付けると、これまで通りにすべてのストレージにアクセスができる。これはファイルマネージャのような特別なアプリを想定しており、アプリ申請時に、MANAGE_EXTERNAL_STORAGE権限が必要な理由をGoogleに説明する必要がある。読書尚友やEBPocketのような一般的なアプリで申請が通るかどうかは不明だが、おそらくrejectされる可能性が高い。

support.google.com


これまで公開されているスケジュールでは、次のようになっていた。

  • 2021年8月 新しいアプリは、Android 11(API レベル 30)以上を対象とする必要がある
  • 2021年11月 既存のアプリの新しいアプリ アップデートは、Android 11(API レベル 30)以上を対象とする必要がある

当方の心づもりとしては、11月までに読書尚友とEBPocketの更新を終え、それ以後は開発を凍結するつもりだった。

ebstudio.hatenablog.com


ところが、今回のGoogle Playのメッセージでは、「requestLegacyExternalStorageフラグをtrueにしているアプリは、強制的に Google Playから削除される」というように読める。これはさすがに一大事だ。これまでそのようなアナウンスはなかったので、本当にそんな乱暴なことをするかどうか疑問だが、なんらかの対応は考えておかないといけない。

読書尚友の対象範囲別ストレージ対応

読書尚友は青空文庫ビューアだが、ローカルファイルのテキストを閲覧できることを特徴にしている。ファイルブラウザの機能を削除して青空文庫ビューアだけに限定すれば、requestLegacyExternalStorageフラグを外すことは簡単だが、読書尚友の存在価値がなくなってしまう。悩んだ挙句、読書尚友については、正攻法で対象範囲別ストレージ対応を行うことにした。
基本的には、次のマニュアルにある通り、ACTION_OPEN_DOCUMENT_TREE インテントを実行してディレクトリを選択し、ディレクトリ ツリーへのアクセス権を付与する。選択したディレクトリのUriが返却されるので、これを使用して、ファイル操作を行う。

developer.android.com

public void openDirectory(Uri uriToLoad) {
    // Choose a directory using the system's file picker.
    Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);

    // Provide read access to files and sub-directories in the user-selected
    // directory.
    intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);

    // Optionally, specify a URI for the directory that should be opened in
    // the system file picker when it loads.
    intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, uriToLoad);

    startActivityForResult(intent, REQCODE_OPENTREE);
}

@Override
public void onActivityResult(int requestCode, int resultCode,
        Intent resultData) {
    if (requestCode == REQCODE_OPENTREE
            && resultCode == Activity.RESULT_OK) {
        // The result data contains a URI for the document or directory that
        // the user selected.
        Uri treeUri= null;
        if (resultData != null) {
            treeUri= resultData.getData();
            // Perform operations on the document using its URI.

        }
    }
}

パフォーマンス問題にはまる

よく見かけるサンプルプログラムだと、Uri を DocumentFile に変換して操作しているものが多い。ところが、うっかり下図のようなコーディングをすると、滅茶苦茶に遅い。実機で300件ぐらいのファイルの情報の取得に6秒ぐらいかかる。これではファイルブラウザとして使い物にならない。

DocumentFile documentFile = DocumentFile.fromTreeUri(getActivity(), treeUri);

for ( DocumentFile file :  documentFile.listFiles() ) {

// 悪い例。滅茶苦茶遅い!

Uri documentUri = file.getUri(); String displayName = file.getName(); long size = file.length(); long lastModified = file.lastModified(); boolean isDirectory = file.isDirectory(); ... }

遅い理由は、DocumentFileのメソッドの getName(), length(), lastModified(), isDirectory()  の呼び出し毎に、内部で毎回queryを実行しているからである。天下のGoogleとは思えないようなひどい実装である。
解決策は、DocumentFileは使わずに、次のようにContentResolver.queryを実行すれば、queryの回数は1回で済む。1000件ぐらいのファイル数でも一瞬で終わる。

String[] projection = {
    DocumentsContract.Document.COLUMN_DISPLAY_NAME,
    DocumentsContract.Document.COLUMN_SIZE,
    DocumentsContract.Document.COLUMN_LAST_MODIFIED,
    DocumentsContract.Document.COLUMN_MIME_TYPE,
    DocumentsContract.Document.COLUMN_DOCUMENT_ID,
  };

Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, DocumentsContract.getDocumentId(treeUri));

Cursor cursor = getActivity().getContentResolver()
		.query(childrenUri, projection, null, null, null, null);
try {
    if (cursor != null && cursor.moveToFirst()) {
        do {
            String displayName = "";
            boolean isDirectory = false;
            long size = 0;
            long lastModified = 0;

            int nameIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME);
            int sizeIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_SIZE);
            int dateModifiedIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_LAST_MODIFIED);
            int mimeIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE);
            int documentIdIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID);

            String documentId = cursor.getString(documentIdIndex);
            Uri documentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, documentId);

            displayName = cursor.getString(nameIndex);
            size = cursor.getLong(sizeIndex);
            lastModified = cursor.getLong(dateModifiedIndex);
            String mimeType = cursor.getString(mimeIndex);
            isDirectory = mimeType.compareTo(DocumentsContract.Document.MIME_TYPE_DIR) == 0;

        } while (cursor.moveToNext());
    }
} finally {
    cursor.close();
}

 あとは、これまでjava.io.Fileを使用していた個所を、Uriベースに書き換えていけばよい(UriをFileに変換できればいいが、ACTION_OPEN_DOCUMENT_TREEで取得したUriはFileに変換できない)。UriはInputStreamに変換できるので、InputStreamに対応した外部ライブラリも使用できる。

InputStream stream = context.getContentResolver().openInputStream(uri);

これでなんとか対象範囲別ストレージ対応を終えることができた。

なお、Android 10 の実機検証は Alldocube iplay40 で行った。Android11の検証はエミュレータを用いた。

EBPocketの対象範囲別ストレージへの対応

次はEBPocketだが、EBPocketはネイティブのC++ライブラリで辞書ファイルにアクセスしているため、Srorage Access Framework を使用することができない。GoogleJavaでファイルを開いてファイル記述子をネイティブライブラリに渡す方法を推奨しているが、辞書ファイルの中には画像や動画ファイルが数千個あるような辞書もあり、とても実現不可能である。
対応としては、次の3つが考えられる。

  1. 共有ストレージの利用をあきらめて、アプリ固有領域( /storage/emulated/0/Android/data/info.ebstudio.ebpocket/files/ )に辞書をコピーして運用してもらう。問題は、EBPocketをアンインストールすると辞書も削除されてしまうこと。また、他の辞書アプリと辞書を共有できない。かなりの制限なので、きっと評価に☆1をつけられるだろう。
  2. 一か八か、MANAGE_EXTERNAL_STORAGE権限を付けて申請してみる。だが受理されるかどうかはわからない。
  3. 本当にGoogle Playからアプリが削除されるまで静観する。そもそも requestLegacyExternalStorage フラグを使うことは違反ではない。

とりあえず静観してみて、削除される可能性が出てきたら 1. か 2. で行こうと思っている。

 

読書尚友にポップアップ辞書を実装

読書尚友のこれまでの辞書連携機能は、EBPocketを外部アプリとして起動するものだったが、画面遷移を伴うため、読書の集中が途切れてしまう問題があった。今回、ポップアップ辞書機能をやっと実現できた。

「CD-ROM版 新潮文庫の100冊」の『狭き門』を読みながら辞書引きする図。

f:id:hishida:20210410095550j:plain

実現方法は、EBPocket に ContentProvider を実装し、読書尚友からContentResolverを通して呼び出し、検索結果をPopupWindowで表示している。
ContentProviderとは、アプリのデータベースの内容を他のアプリに公開する機能であり、ActivityやServiceと同じように、Androidの最初期から提供されている基本的なコンポーネントである。Android1.6から2.3ぐらいまでは、Androidのクイック検索ボックスからアプリ内のデータベース検索ができたので、EBPocketもContentProviderを実装していた。ところがいつ頃からかクイック検索ボックスからアプリを検索する機能がなくなったため、EBPocketもContentProviderを非公開にしていた。今回、読書尚友から呼び出せるように修正し、再度公開した。

EBPocketはProでもFreeでもかまわない。ただしVer1.48以降が必要。読書尚友を以前からご使用の方は、設定でポップアップに設定する必要がある。

f:id:hishida:20210410102043j:plain

一つ苦労したのは、外字ビットマップや画像を、どうやって表示するかだった。これは画像をbase64でHTML中に直接埋め込むことで解決できた。

検索方法(前方一致や完全一致など)や、検索対象の辞書は、EBPocketの設定に従う。大量の辞書の串刺し検索ではどうしてもポップアップまでに時間がかかるので、青空文庫を読むなら、広辞苑ぐらいの中規模国語辞典+漢字辞書+百科事典ぐらいがちょうどいい。
これで辞書引きが非常に楽しくなった。

読書尚友で段組に対応

kindle本やPDFを読むのにタブレットがほしくなって、ALLDOCUBE iPlay40 という10インチタブレットを買ってしまった。iPadよりも安いことと、テスト用にAndroid 10 の実機が必要という理由からだ。
なぜかイヤホンジャックがないとか、Amazon Primeの動画がSD画質になってしまう(Widevine のレベルがL3のため)という欠点はあるが、読書端末としてはほとんど不満はない。
しばらく使っているうちに、横向き画面のときに、読書尚友で段組表示ができないことが不満に思えてきた。結構苦労したが、なんとか実現できた。

f:id:hishida:20210330174723p:plain

 

f:id:hishida:20210330174738p:plain

 

f:id:hishida:20210330174752p:plain

 

今のところ横書きで左右二段組だけ対応している。縦書きで上下二段も実現の手間はそれほど変わらないが、ニーズがないような気がしたので、今回は見送った。

 

読書尚友に書き出しと著者画像を追加する

この一ヶ月ほど、読書尚友の改良を重点的に行っている。最近の改良点は:

  • 児童書の分野別検索(NDC)
  • 書籍一覧に書き出しを表示
  • 作家別リストに著者画像を表示(Wikipediaより)
  • 次・前の検索語のボタン
  • 本文に時刻表示

今回の改良の中で、特筆すべきなのは、書き出しと著者画像だと思う。どちらも、PythonでWebスクレイピングを行って情報を取得している。Pythonは初めて使ってみたが、使いやすい。結構古くからある言語なので、EBシリーズ関連のスクリプトRubyではなくPythonを採用しておけばよかったかもしれない。 

書き出しの表示

書店で書籍を選ぶとき、目次と書き出しを見て、まず読むに値するかどうかを決めると思う。書籍一覧に書き出しまで表示すると情報過多な気もするが、意外に便利だ。もちろん、設定で書き出しをオフにすることもできる。

書き出しの例

f:id:hishida:20210321154857p:plain

書き出しは、青空文庫の図書カードのHTMLのMETA要素に含まれている。例えば「坊ちゃん」の図書カードのヘッダは次のようになっている。ここから、 property="og:description"の要素を取り出す。

<html lang="ja" xmlns:og="http://ogp.me/ns#">
<head>
<meta charset="utf-8">
<meta property="og:type" content="book">
<meta property="og:url" content="https://www.aozora.gr.jp/cards/000148/card752.html">
<meta property="og:image" content="https://www.aozora.gr.jp/images/top_logo_300x300.png">
<meta property="og:image:type" content="image/png">
<meta property="og:title" content="坊っちゃん (夏目 漱石)">
<meta property="og:description" content="一 親譲(おやゆず)りの無鉄砲(むてっぽう)で小供の時から損ばかりしている。小学校に居る時分学校の二階から飛び降りて一週間ほど腰(こし)を抜(ぬ)かした事がある。なぜそんな無闇(むやみ)をしたと聞く人…">
<!-- OGP: thanks to @cc4966 https://github.com/aozorahack/ogp -->
<meta name="twitter:card" content="summary" />
<meta http-equiv="Content-Type" content="text/html;charset=utf-8">
<meta http-equiv="Content-Style-Type" content="text/css">
<title>図書カード:坊っちゃん</title>

Pythonで書き出しを取得するスクリプトは次のようになる。requestsとBeautifulSoupを使用する。 

import requests
from bs4 import BeautifulSoup

url = "https://www.aozora.gr.jp/cards/000148/card752.html"
card = requests.get( url )
soup = BeautifulSoup( card.content, "html.parser")
meta = soup.select_one('meta[property="og:description"]')
with open( "kakidasi.txt" ,"w") as file:
	file.write( meta['content'])

 
著者画像の表示

 著者画像もあると興味が湧いて、覗いてみたくなったりする。著者画像は、著作権の関係もあるので、全てWikipediaから取得している。

著者画像の例

f:id:hishida:20210321154915p:plain

著者画像を取得するスクリプトの例を示す。青空文庫夏目漱石の著者カード(person148.html")からWikipediaへのリンクを探し、続いてWikipwdiaから著者画像を取得している。

import requests
from bs4 import BeautifulSoup

# 著者ページを取得
person_url = "https://www.aozora.gr.jp/index_pages/person148.html"
card = requests.get( person_url )
soup0 = BeautifulSoup( card.content, "html.parser")

# wikipedia のリンクを取得
links = [url.get('href') for url in soup0.find_all('a')]
for link in links :
	if not link :
		continue

	if link.find("wikipedia.org/wiki/") >0 :
		# 著者画像を取得
		response = requests. get( link)
		soup = BeautifulSoup( response.content, "html.parser")

		meta = soup.select_one('meta[property="og:image"]')
		if not meta :
			continue
		image_url = meta['content']

		image_data = requests.get( image_url)
		with open( "148.jpg","wb") as file:
			file.write( image_data. content)
		break

 将来構想?

あと実現したいとすれば、端末を横にしたときの段組表示である。スマートフォンだとあまり必要性を感じなかったが、Windows10の検証用に中華タブレット(iPlay40)を買ったところ、横組みの時は段組が必須だと思うようになった。

読書尚友の改良3点

読書尚友に、最近3点の改良を行った。

ファイル一覧でPDFの表紙画像のサムネイルを表示

PdfRendererで先頭ページの画像を取得し、縮小表示している。

f:id:hishida:20210118081857j:plain

読み上げのバックグラウンド再生

text-to-speechでテキストの読み上げを行う場合、Activity上での実行のため、アプリがバックグラウンドに回ったり、端末がスリープすると再生が止まってしまうという問題があった。通勤時間中に青空文庫をイヤホンで聴きたいというニーズがあるはずだが、実際は無理だった。
今回、text-to-speechを Service を用いて別プロセスで再生するようにしたので、音楽アプリのように、端末がスリープしても再生できるようになった。テキストの読み上げ箇所を画面表示するためには、ServiceからActivityへコールバックする必要があるが、Messengerを使った通信で実現している。また、バックグラウンド再生中は通知領域に表示し、タップすることでフォアグラウンドへ復帰するようにしている。

ePubのWebViewによる表示

ePub文書を、これまでは独自ビューアで表示していたが、CSSを活用したレイアウト重視のePubの表示が貧弱で、期待されるレイアウト通りに表示されないという問題があった。そこで、WebViewを用いたブラウザによるePub表示モードを追加した。オーバースクロール(スクロール端で引っ張って離す)による前後の文書への移動、文書内検索、メニューに対応している。

独自ビューだとCSSによるレイアウトが無視されてしまうが。。。

f:id:hishida:20210118082012j:plain

独自ビューアによるePub表示

WebViewだと次のようにきれいにレイアウトされる。

f:id:hishida:20210321161204j:plain

WebViewによるePub表示



ただしテキスト中心の縦書き表示だと、従来の独自ビューアのほうが読みやすい場合もあるので、設定で切り替えができるようにしている。

これでほとんどのePub文書が、一応は読めるようにはなった。ただ個人的には、ePubリーダーとしては、Androidなら楽天koboのビューアかGoogle Play Books、iOSならApple Books がお薦めである。

 

Android Q の外部ストレージアクセスについて

2020年11月以降、Google Play で既存アプリをアップデートするには、targetSDKを29(Android Q)以上にする必要がある。
ところが一つ問題があって、Android Qからは、対象範囲別ストレージ(Scoped storage)が導入され、外部ストレージのアクセスが厳格化された。

developers-jp.googleblog.com


Android P までは、READ_EXTERNAL_STORAGEパーミッションがあれば、外部ストレージのどこでもアクセスができたが、対象範囲別ストレージのもとでは、アクセス可能な領域が限定される。

  1. getFilesDir()で取得できるアプリ専用の内部ストレージ
  2. getExternalFilesDir()で取得できるアプリ専用の外部ストレージ
  3. StorageAccessFrameworkによって選択したファイル
  4. MediaStore APIによるアクセス(画像、動画、音楽、ダウンロードファイル)

ただし、移行期間として、マニフェスト ファイルで requestLegacyExternalStorage の値を true に設定すると、対象範囲別ストレージがオプトアウト(無効化)され、Android Pまでと同様のフルアクセスが可能になる。
現在EBPocket for Androidと読書尚友では、とりあえずrequestLegacyExternalStorageをtrueに設定して、下位互換性を保っている (11月以降のアップデートで一時Android10での外部ストレージアクセスができなくなる問題が出てご迷惑をかけたが、現在は修正済み)。

だが、2021年11月からはGoogle Playに提出するにはtargetSDKを30以上にする必要があり、そうするとrequestLegacyExternalStorageが無効化されてしまう。つまり、対象範囲別ストレージに対応できないと、今後アプリのアップデートができなくなってしまう。

developer.android.com


EBPocketはSDカードに辞書を置いて運用するスタイルなので、対応が難しそうに思う。

実現可能な方法としては、アプリ固有の外部ストレージ領域に辞書を置いてもらう方法があるが、アプリを削除するとデータ領域も削除されるので、アンインストール・インストールすると辞書を再度コピーしなければならない。iOS版のEBPocketは現在でもそうだが、これまでのAndroid版のユーザには受け入れられないだろう。

2021年11月までに対応ができない場合は、アップデートの凍結が最善かもしれない。targetSDKを30以上にしない限り、Android11の端末でも、(requestLegacyExternalStorageによって)対象範囲別ストレージは適用されず、相変わらず自由なアクセスが可能だからだ。