hishidaの開発blog

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

読書尚友 iOS版 テスター募集

以前告知した読書尚友(青空文庫ビューア)のiOS版の開発がある程度進んだので、テスターを募集します。
テスターを希望される方は、下記のリンクからメールアドレスを登録してください。

https://ssl.form-mailer.jp/fms/574405c4714498

24時間以内に次のような招待メールが届きます。 App Storeから Test Flight をインストールし、メール本文の”View in TestFlight”を押すと、Test Flightから読書尚友のパイロット版をインストールできます。Test Flightからフィードバックを送信することができます。

f:id:hishida:20210804182648p:plain

テスターは先着100名に達するか、読書尚友の正式版をリリースした時点で募集を締め切ります。
パイロット版は本日から90日間(10/31まで)使用できます。

青空文庫関連の機能はほぼAndroid版と同じで、PDFも読めます。
今のところAndroid版にあってiOS版にない機能は、ePubビューアと 音声読み上げです。希望があれば追加を検討します。

f:id:hishida:20210802194715p:plain

f:id:hishida:20210802194816p:plain

f:id:hishida:20210802194857p:plain

f:id:hishida:20210802194940p:plain



M1 MacBook Air を購入した

5年近く利用したMacBook Pro (Retina, 13-inch, Early 2015)を、M1 MacBook Air に買い換えた。Apple Siliconでのテストがしたかったことが一番の理由だが、最近Pallarels Desktop 16.5が ARM 版Windowsを正式サポートしたことが決め手になった。

ユニバーサルバイナリのテスト

まず最初に確認したかったのは、ユニバーサルバイナリとしてリリースしているEBMacやEBStudio for Macが、本当にApple Silicon版で動作しているかどうかだ。アクティビティモニタで確認すると、種類の表示がちゃんとAppleになっている。これでApple Silicon版が動作していることが確認できた。

f:id:hishida:20210621201149p:plain

アクティビティモニタ

AppStoreからEBPocketを導入する

ひとつ気になっていたのは、AppStoreからiOS用のアプリが導入できるらしいということ。ということはEBPocketも導入できるのでは?

実際にAppStoreで "EPWING"で検索すると、EBPocket のpro版とfree版が表示される。Mac版のアプリと同様に問題なくインストールでき、実行も問題ない。画面のサイズを自由に変更できるiPad版アプリとして動作するようだ。

f:id:hishida:20210621201333p:plain

AppStoreの表示

一つ困ったことは、iOS版のアプリのドキュメントフォルダにデータをコピーする方法が見つからないことだ。EBPocketの場合は、辞書データをドキュメントフォルダに転送できないと、事実上使い物にならない。

iOSの実機だとファインダー(WindowsだとiTunes)でファイル転送ができるが、ファインダーではBig Sur上で動いているiOSアプリにファイル転送ができないようだ。

実はEBPocketはデータを転送する方法としてファインダ(iTunes)によるファイル転送のほかに、FTP転送を用意している。EBPocketでFTPサーバーを起動し、Mac用のFTPクライアントを使用すれば、辞書データをファイル転送することが可能だ。

f:id:hishida:20210621201437p:plain

FTPクライアントによる辞書の転送

これでMac上でEBPocket for iOSとEBMacが同時に使用できるようになった。さらにPallarels Desktop上のARM版Windows上でEBWin4が動作するので、プラットフォームの違うEBシリーズを同時に使用できるというカオスな環境になった。

f:id:hishida:20210621201518p:plain

EBPocket for iOSとEBMacとEBWin4を同時に動かす

Pallarels Desktopで ARM版Windowsを使う

さて、せっかくのM1 Macなので、Pallarels DesktopでARM 版Windows10を使ってみたい。ARM 版Windowsは販売されていないが、開発者登録を行えばInserder Preview版を無料でダウンロードできる。

Pallarels Desktop 16.5でARM 版Windows10をインストールしてみたところ、日本語ランゲージパックをインストールしてキーボードを日本語に設定すれば、問題なく使用できることがわかった。(ただし今のところ、32bitアプリしか動かせないらしい。)

日常的に使用するMicrosoft Office 365、秀丸、Visual Studio2019、TeraTermFFFTPRubyPythonなどが正常動作することを確認した。テキスト作成の用途なら十分実用可能だし、開発もできるかもしれない。さすがにWindows用のアプリの開発はネイティブのWindowsのほうが快適なので、今後もSurface Pro 6 と併用することになると思う。

なお、拙作のEBWin4とKWIC Finder 4(どちらも32bit版)も問題なく使用できた。Visual C++再配布可能パッケージの導入もいらなかった。

これで日常的に持ち歩くPCをSurface Pro6からMacbook Airに変えたので、今後しばらくは iOS用のアプリの開発を中心に行うつもりである。

iOS版の読書尚友の開発状況だが、順調に進んでいるので、もうしばらくしたらご紹介できると思う。

読書尚友 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)を買ったところ、横組みの時は段組が必須だと思うようになった。