hishidaの開発blog

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

M1 Mac で読書尚友のすすめ

読書尚友 for iOSをリリースして2週間が経つが、ダウンロード数は期待したほど伸びない。EBシリーズも読書リーダーもライフワークなので、気にせず開発を進めることにする。差し当たっては、Android版のみの機能がまだ残っているので徐々に移行を進めたい(ePub簡易表示、音声読み上げなど)

読書尚友 - 青空文庫ビューア

読書尚友 - 青空文庫ビューア

  • hishida
  • ブック
  • 無料

apps.apple.com

今回の開発は、Swift言語で実用的なサイズのアプリを作れたことが最大の収穫と思っている。もっと早くiOS青空文庫リーダーに参入したかったが、Swift言語の仕様が安定して破壊的なAPIの変更がなくなったのは比較的最近なので、かえって良かった面もある。CocoaPodsで豊富なライブラリを利用できる利点もあり、開発は非常にスムーズだった。開発の主流がだんだんSwift UIに移りそうだが、今回はリスクを避けて通常のSwiftで開発した。
ところで、M1 Mac ではiOS用のアプリをAppStoreからダウンロードできる(デベロッパーが許可していれば)。実は読書尚友 for iOSもM1 Macで実行することが可能だ。Mac青空文庫ビューアとして読書尚友をおすすめしたい。

f:id:hishida:20210919161421p:plain

M1 Macで読書尚友を使う

読書尚友には、選択範囲の文字列を内蔵辞書で検索する機能があるが、M1 Macで実行した場合は、辞書.appを辞書引きに使用する。EBPocket for iOSをダウンロードすれば、辞書アプリにEBPocket を利用することもできる。
ほかに、青空文庫一括ダウンロードなどの便利な機能も利用できる。

読書尚友のAndroid版のほうでは、最近、.docx、.odtの簡易表示に対応した。xdoc2txtの機能の一部をjavaに移植した。Open Office XMLxmlをzipで固めたもので、ePubに近いので、対応は難しくない。テキスト要素とルビの表示に対応している。Microsoft Word2003までの.doc形式はバイナリなので対応がやっかいなのと、docxのほうが主流になってきたと思うので、対応は見送った。

 

読書尚友 iOS版 公開

読書尚友 iOS版を公開しました。 

読書尚友 - 青空文庫ビューア

読書尚友 - 青空文庫ビューア

  • hishida
  • ブック
  • 無料

apps.apple.com

 広告付フリーソフトです。アプリ内購入による広告解除を追加予定です。

  • Android版の機能を踏襲していますが、次の機能は実装していません。今後の対応は未定です。

  - ePub
  - 音声読み上げ
  - OPDSライブラリ

  • テキスト、PDF、画像zip/rarに対応しています。

ストア版公開に伴い、公開テスターは終了させていただきます。テスターの皆様、ご協力ありがとうございました。

 

読書尚友 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版にない機能は、画像zipと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の設定に従う。大量の辞書の串刺し検索ではどうしてもポップアップまでに時間がかかるので、青空文庫を読むなら、広辞苑ぐらいの中規模国語辞典+漢字辞書+百科事典ぐらいがちょうどいい。
これで辞書引きが非常に楽しくなった。