hishidaの開発blog

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

読書尚友 for iOS ePub・テキスト読み上げ対応

読書尚友 for iOS のリリースから早いもので1年が過ぎた。開発予定の機能で残っていた、「ePub対応」と「テキスト読み上げ」の実装がやっと終わったのでリリースした。

ePub対応は、Android版の機能をほぼJavaからSwiftに移植しただけなので、表現力はほぼ同等である。iOSePubを読むなら定番のブックアプリがあるので、対応の必要はあまりないと思っていたが、ブックにePubを転送するのは割と面倒に感じる。ローカルのePubを日本語縦書きビューアで気軽に読む用途には、使えるのではないだろうか。

テキスト読み上げは、AVSpeechSynthesizer を利用した。Androidに比べると、ずっと簡単に実装できた。バックグラウンドでの再生にも対応しているので、通勤中に耳で読書するような用途にも使えると思う。ただ、肝心のテキスト読み上げのエンジンがAndroidの方が優秀で、iOSだとけっこう不自然な読み方をされてしまう。ちなみに読み上げエンジンはデフォルト(Kyokoというらしい)のほかに、Siriの音声も選択でき、Siriのほうが少し滑らかなようだ。

 

iPhone XS導入

iOS16の対応機種がiPhone8以降となり、小生の所有するiPhone SE(初代)はサポート対象から外れてしまった。
エミュレータがあればiOS16の動作確認はできるが、どうしても実機でないと分からない部分もあり、iOS16対応機種を購入する必要性に迫られた。
要はiOS16以降対応ならよいので、中古のiPhone XSを購入することにした。SE2のほうがコスパはよいのだが、ノッチ付端末の実機をテスト用に欲しかったという事情もある。性能的には現在でも十分通用するし、中古価格も256Gで4万円台で入手できる。
iPhone SEY!mobileのSIMは、n141という、「ワイモバイル版のiPhoneでしか使えない」というものだが、SIMフリー版のXSに差し替えたところ、問題なく使用できた。
バッテリは購入時83%だったが、待ち受けで3日ぐらいは持つので、バッテリ交換はまだ先延ばしできそうである。

さてiPhone XSで読書尚友を動かしたところ、ノッチ付端末特有の不具合が色々とあることがわかった。

  1. タイトル行がノッチに重なってしまう
  2.  画面下部のページ移動のスライダーが、スワイプ領域と重なるため押せない(エミュレータだとマウスで押せるので、気がつかない)

とりあえずノッチ付端末用に表示を調整したバージョンを緊急リリースした。

EBPocketのほうはiPhoneXが出たときに苦労して対応した甲斐があって、特に問題はなかった。

本当は読み上げ機能やePub対応など、まだ実装していない機能があるが、開発のモチベーションが上がらない。気分が上向いてきたら、再開できると思う。

 

読書尚友 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版にない機能は、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. で行こうと思っている。