📜

fusetterみたいなものをFlutterで作った話

2024/01/21に公開

車輪の再発明って本当に楽しいですね。Blueskyにはまだfusetterみたいなものがないので、画像のALTにネタバレを書けるfusetterみたいなものを作ってみました。

https://eyasuyuki.github.io/bluespoiler/

Flutterを書くのが2年ぶりでずいぶん回り道もしたので記録として書いておきます。

全ソースコードはGitHubにあります。https://github.com/eyasuyuki/bluespoiler

fusetterの伏せ字アルゴリズムを勝手に推測する

fusetterは[と]で括った部分が伏せ字になりますが、どういうアルゴリズムで動いているんでしょうか? fusetterのJavaScriptソースを盗み見てパクっても面白くないので勝手に推測することにしました。

まず開き括弧と閉じ括弧の出現位置を保存しておくことにします。

// pair of brackets
class Pair {
  // field
  int start = 0;
  int end = 0;
  // constructor
  Pair({this.start = 0, this.end=0});
}

開き括弧と閉じ括弧の対応は、開き括弧の出現位置だけをスタックに積むことで解析します。

void setInput(String input) {
  _input = input;
  alt = [];
  List<int> stack = []; // list of '[' positions
  List<Pair> brackets = []; // list of bracket pairs

  for (int i = 0; i < _input.length; i++) {
    if (input[i] == '[') {
      stack.add(i);
    } else if (_input[i] == ']' && stack.isNotEmpty) {
      int start = stack.last;
      if (brackets.isNotEmpty) {
        Pair prev = brackets.last;
        if (start < prev.start && i > prev.end) {
          brackets.removeLast();
        }
      }
      Pair p = Pair(start: start, end: i);
      brackets.add(p);
      stack.removeLast();
    }
  }

閉じ括弧が出現したら、スタックからpopしてPairを生成してbracketsに追加します。この際、一つ前のPairと比較して外側の括弧Pairならひとつ前を削除する制御を行っています。

最終的にできたPair(括弧位置)の配列であるbracketsを使って平文文字列と伏せ字文字列を生成して行きます。

TextEditingControllerにRiverpodは使うな

当初状態管理はRiverpodで書こうとしていたのですが、最も苦労したのがTextFormFieldに入力された文字をリアルタイムでTextに表示してプレビューする部分。StreamProviderを使うべきなのかとか散々調べた結果、Riverpodの作者のRemiさんがTextEditingControllerにRiverpodは非推奨なのでStatefulWidgetかflutter_hooksを使えと言っているのを発見。

https://github.com/rrousselGit/riverpod/discussions/2680

これを発見しなかったら何週間無駄にしたかと思うとゾッとします。結局HookWidgetでサクッと書けました。

flutter_loginは便利だが、そもそも画面遷移する必要あるの?

fusetterはTwitter APIで最初にアプリ連携してから記事を書くようになっているけども、BlueskyにはそういうものがないのでユーザーIDとパスワードでセッションを生成する必要があります。

final session = await bsky.createSession(
  identifier: emailController.text,
  password: passwordController.text
);

ユーザーIDとパスワードの入力画面が必要なので、当初は記事入力画面でPostボタンを押したらflutter_loginで書いたログイン画面に遷移するようにしていました。でも一晩寝て起きてみるとそもそも画面遷移って必要なの? という疑問が浮かんだのです。

最終的には同じ画面でメールアドレスとパスワードを入力させるように変更しました。

Blueskyへの投稿はとても簡単

Bluesky APIを利用するためにblueskyパッケージを使用します。

flutter pub add bluesky

Blueskyへの投稿はこれだけ。

final session = await bsky.createSession(
  identifier: emailController.text,
  password: passwordController.text
);
final bluesky = bsky.Bluesky.fromSession(session.data);
final uploaded = await bluesky.repo.uploadBlob(imageBytes.value!);
final post = bluesky.feed.post(
  text: body,
  embed: bsky.Embed.images(
    data: bsky.EmbedImages(
      images: [
        bsky.Image(
          alt: alt,
          image: uploaded.data.blob,
        ),
      ],
    )
  ),
);

画像ファイル選択と表示

前項で画像を保持していたimageBytesですがこれはどうやって定義したのでしょうか。
HookWidgetbuildメソッドの最初で以下の定義を行なっています。

final imageBytes = useState<Uint8List?>(null);

画像ファイルの選択はimage_picker_webを使います。

flutter pub add image_picker_web

画面側ではImagePickerWeb.getImageAsByteを呼び出します。

Future<void> pickImage() async {
  const maxImageSize = 999997;
  try {
    Uint8List? uint8list = await ImagePickerWeb.getImageAsBytes();
    if (uint8list!.lengthInBytes > maxImageSize) { // 画像サイズチェック
      ScaffoldMessenger.of(context).showSnackBar(  // サイズ超過は警告
        SnackBar(
          content: Text(AppLocalizations.of(context)!.image_size_text),
          backgroundColor: Colors.redAccent,
        ),
      );
    } else {
      imageBytes.value = uint8list; // 選択された画像でimageBytesの値を更新
    }
  } catch (e) {
    print(e);
  }
}

maxImageSizeはBlueskyの画像の最大バイト数です。これを超える画像は使用できません。
画像ファイルのサイズをチェックし、これを超える場合はSnackBarで警告します。

画面側ではボタンのonPressedpickImageを呼び出すようにします。

ElevatedButton(
  onPressed: () async {
    await pickImage();
  },
  child: Text(AppLocalizations.of(context)!.image_button_text)
),

選択された画像をImageに表示します。

Row(mainAxisAlignment: MainAxisAlignment.center, children: [
  Image.memory(
    imageBytes.value!,
    width: 200,
    height: 200,
  ),
  CloseButton(onPressed: () {
      imageBytes.value = null;
  })
])

画像の横にCloseButtonを表示し、押されたらimageBytesの値をクリアするようにしています。

これらウィジェットを組み合わせて、imageBytesの値がnullのときは画像選択ボタンを表示し、画像が選択されていたらその画像をImageに表示するようにします。

imageBytes.value != null
? Row(mainAxisAlignment: MainAxisAlignment.center, children: [
    Image.memory(
      imageBytes.value!,
      width: 200,
      height: 200,
    ),
    CloseButton(onPressed: () {
        imageBytes.value = null;
    })
])
: ElevatedButton(
  onPressed: () async {
    await pickImage();
  },
  child: Text(AppLocalizations.of(context)!.image_button_text)),

Blueskyの記事ページURLを推定する

Blueskyeへのpostの戻り値としてuriとcidが返ってきます。

{"uri":"at://did:plc:dptps7rgxju4nrg6qskop2wz/app.bsky.feed.post/3kjmvo7ocl62e","cid":"bafyreihxcb3n6hxucw3hdeb3kylhzkoumitkx4hygoy24tyxt4hemajdde"}

このuriはatUriというもので、ATプロトコルにおいて記事をユニークに指し示しています。Blueskyの記事のURLは次のようになります。

https://bsky.app/profile/javaopen.org/post/3kjmvo7ocl62e

profileの次のパスjavaopen.orgは投稿者のハンドル、最後の3kjmvo7ocl62eはatUriの最後のパスと一致しています。

atUriの最後のパスを得るためUri.parseで解析するとportが不正だというエラーになります。

final uri = Uri.parse('at://did:plc:dptps7rgxju4nrg6qskop2wz/app.bsky.feed.post/3kjmvo7ocl62e');
Unhandled exception:
FormatException: Invalid port (at character 10)
at://did:plc:dptps7rgxju4nrg6qskop2wz/app.bsky.feed.post/3kjmvo7ocl62e
         ^

たしかにportっぽいものが数字じゃないのでUri.parseは使えません。

他にも方法はあるのかも知れませんが、String.split'/'を使って分割することにします。

final articleId = post.data.uri.href.split('/').last;

投稿者のハンドルはsessionから得られます。

再掲

final session = await bsky.createSession(
  identifier: emailController.text,
  password: passwordController.text
);

投稿者のハンドルはsession.data.handleから得られます。これで記事のURLが生成できます。

final url = Uri(
  scheme: 'https',
  host: 'bsky.app',
  pathSegments: <String>[
    'profile',
    session.data.handle,
    'post',
    articleId,
  ],
);

テスト用のユーザーIDとパスワードを秘匿する

Bluesky APIとの接続部分のテストがなかったので書きます。mockitoを使う方が良いのでしょうが、とりあえず本物のサーバーに接続するテストを書くことにします。

今回は本物のユーザーIDとパスワードを秘匿するため、以下の手順でflutter_dotenvを使用します。

  1. .gitignore.envを追加する
  2. プロジェクトルートに.envファイルを作り環境変数を記述する。今回はBLUESKY_IDENTIFIERBLUESKY_PASSWORD
  3. コマンドラインでflutter pub add flutter_dotenvを実行しライブラリを使用可能にする
  4. テストの中でdotenvを使用して環境変数を読み込む。

.envファイル(架空の設定値です)

BLUESKY_IDENTIFIER=eyasuyuki
BLUESKY_PASSWORD=EotTromABdPCWHlR

テストコード

void main() async {
  await dotenv.load(fileName: '.env');
  final id = dotenv.get('BLUESKY_IDENTIFIER');
  final password = dotenv.get('BLUESKY_PASSWORD');

環境変数の読み込みはString.fromEnvironmentPlatform.environmentではなくdotenv.getを使用します。

Riverpodの単体テストを書く

Bluesky APIとやりとりする部分はボタンのonPressedにベタ書きしていました。これを分離してテストも書きたいのでRiverpodで書き直すことにしました。


Future<bool> testLogin(TestLoginRef ref, {required String email, required String password}) async {
  try {
    final session = await bsky.createSession(
        identifier: email,
        password: password
    );
    switch (session.status.code) {
      case 200:
        return true;
      case 204:
        return true;
      default:
        return false;
    }
  } catch (e) {
    return false;
  }
}

テストではProviderContainerを使って、自動生成されたtestLoginProviderreadしています。IDとパスワードは前述の方法で秘匿した本物のアカウントを使用して本当にBlueskyに接続してテストしています。

  test('test testLogin', () async {
    final container = ProviderContainer();
    // test loading
    expect(
        container.read(testLoginProvider.call(email: id, password: password)),
        const AsyncValue<bool>.loading()
    );
    // test success
    expect(
      await container.read(testLoginProvider.call(email: id, password: password).future),
      true
    );
    // test fail
    expect(
      await container.read(testLoginProvider.call(email: 'email', password: 'password').future),
      false
    );
  });

画面側ではref.readで読み込んだtestLoginProviderを利用しています。Riverpodを使うために、画面側の継承元クラスをHookWidgetからHookConsumerWidgetに変更しています。

TextButton(
    onPressed: () async {
      await ref.read(testLoginProvider.call(email: emailController.text, password: passwordController.text).future)
          ? ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.login_success_text)))
          : ScaffoldMessenger.of(context)
              .showSnackBar(SnackBar(backgroundColor: Colors.redAccent, content: Text(AppLocalizations.of(context)!.login_failed_text)));
    },
    child: Text(AppLocalizations.of(context)!.verify_button_text),
),

(もうちょっとスマートに書けないかとは思っています)

go_routerで前の画面に戻ったときに入力値をクリアする

投稿に成功したら記事のリンクを表示する画面に遷移し、[別のネタバレを投稿する]ボタンを押した時に編集画面に戻るようにしました。

このとき単にcontext.popcontext.goで画面遷移すると入力値がそのまま残ってしまいます。

context.pushcontext.goには引数を渡せるので、呼び出し側では引数を付けて呼び出すようにします。

context.go('/', extra: true) // true: 入力値をクリアする

GoRouterの定義では呼び出し側のextra:に渡された値をstate.extraで取得してコンストラクタ引数として渡しています。

final _router = GoRouter(
  initialLocation: '/',
  initialExtra: false,
  routes: <RouteBase>[
    GoRoute(
      path: '/',
      builder: (BuildContext context, GoRouterState state) {
        return SpoilerEditor(title: title, clearAll: state.extra as bool);
      },
    ),

画面側ではその引数で画面をクリアするかどうかを判定することにします。コンストラクタのオプション引数としてclearAllをとるように変更します。

const SpoilerEditor({super.key, required this.title, this.clearAll = false});

画面の初期化処理はuseEffectを使います。

// use effect
useEffect(() {
  if (clearAll) {
    inputController.clear(); // 記事入力のクリア
    imageBytes.value = null; // 画像データのクリア
    emailController.clear(); // Email入力のクリア
    passwordController.clear(); // パスワード入力のクリア
  }
  return null;
}, [clearAll]);

注意点としては[clearAll]ではなく[]だとclearAllの値が変化していてもuseEffectは実行されません。

Riverpodで入力値を管理するproviderを書く方法など様々あろうかとは思いますが、とりあえずflutter_hooksuseEffectで実現できました。

Postにリンクを埋め込む

URLを含むTweetを行うとTwitterでは勝手にリンクしてくれますが、Blueskyでは単なる文字列として扱われます。

URLを本文中にリンクとして埋め込むにはRich TextとしてPostする必要があります。

DenoでBlueskyに𝑹𝒊𝒄𝒉 𝒕𝒆𝒙𝒕を投稿する という記事に書かれている通り、Postの際にfacetsというRich Textの情報を付加すれば良さそうです。

まず正規表現を使って本文中のURLを抽出することにします。

List<Pair> extractUrl(String input) {
  final _regexp = RegExp(r'https?://[^\s]*');
  final matched = _regexp.allMatches(input);
  return matched.map((e) => Pair(start: e.start, end: e.end)).toList();
}

本文中のURLの開始位置と終了位置が得られたので、これを使ってfacetsを生成することにします。

List<bsky.Facet> createFacets(String input, List<Pair> urls) {
  return urls.map((e) => bsky.Facet(
    index: bsky.ByteSlice(
        byteStart: e.start,
        byteEnd: e.end,
    ),
    features: [
      bsky.FacetFeature.link(
          data: bsky.FacetLink(
              uri: input.substring(e.start, e.end),
          ),
      ),
    ],
  )).toList();
}

Postしてみるとリンクの位置がずれています。

位置がずれた理由ですが、正規表現でマッチした開始位置と終了位置は文字単位なのに対して、Facetindexはバイト単位の位置だからです。というわけでバイト単位の位置を求めるためにPairにメンバーを追加して、もうPairではなくなったのでRegionに名前を変えることにします。

class Region {
  // field
  int start = 0;
  int end = 0;
  int byteStart = 0;
  int byteEnd = 0;
  // constructor
  Region({this.start = 0, this.end = 0, this.byteStart = 0, this.byteEnd = 0});
}

Dartのマルチバイト文字列はUTF-16なので、バイト単位の開始位置、終了位置を求めるにはutf8.encode(文字).lengthを使います。

List<Region> toByteIndices(String input, List<Region> pairs) {
  int byteIndex = 0;
  int prevBytes = 0;
  for (int i = 0; i < input.length; i++) {
    byteIndex += prevBytes;
    for (var p in pairs) {
      if (i == p.start) {
        p.byteStart = byteIndex;
      } else if (i == p.end) {
        p.byteEnd = byteIndex;
      }
    }
    prevBytes = utf8.encode(input[i]).length;
  }
  return pairs;
}

あとはfacetsの生成でバイト位置を使うように変更します。

List<bsky.Facet> createFacets(String input, List<Region> urls) {
  urls = toByteIndices(input, urls); // バイト位置を求める
  return urls.map((e) => bsky.Facet(
    index: bsky.ByteSlice(
        byteStart: e.byteStart, // バイト単位の開始位置
        byteEnd: e.byteEnd,     // バイト単位の終了位置
    ),
    features: [
      bsky.FacetFeature.link(
          data: bsky.FacetLink(
              uri: input.substring(e.start, e.end), // ここは変更なし
          ),
      ),
    ],
  )).toList();
}

位置がずれずにPostできました。

flutter-webアプリケーションとしてリリースした理由

理由としては以下が挙げられます。

  1. 実行、デバッグをChromeで行っていたから
  2. image_picker_webを使っているから
  3. GitHubの公開リポジトリなのでGitHub Pagesにデプロイするのが最も簡単

プロジェクトソースにはios、android、web、macos、windows、linuxそれぞれに対応したディレクトリが存在し各環境向けにビルドできるようにはなっていますが、開発中はもっぱらChromeで実行やデバッグを行っていました。iosやandroidはシミュレータ/エミュレータの設定が必要で、何の設定もなしに起動できるのはChromeとネイティブアプリケーションだけだからです。モバイルアプリケーションへの対応は後からでもできると思っていました。

Bluespoilerでは画像を読み込んでいますが、flutter-webでは画像の読み込みにimage_pickerではなくimage_picker_webを使う必要があります。flutter-webではdart:ioがimportできないためです。このため、モバイル向けビルドではimage_pickerを使い、web向けビルドではimage_picker_webを使うといった設定が本来は必要になります。今回は動くものを先に作りたかったためimage_picker_webを使って開発し、モバイルアプリケーションへの対応は後回しにしました。

Bluespoilerでは全てのソースコードをGitHubのpublicリポジトリで公開しています。Flutter WebをGitHub Pagesにデプロイする方法 という記事を参考に、完成したアプリケーションをGitHub Pagesに公開しました。

いざ公開してみるとGitHub Pagesでflutter-webアプリケーションを公開するのは様々なメリットがあったのです。

  1. デベロッパー登録もアプリ審査も必要ない
  2. ホーム画面にブックマークを追加すればネイティブアプリケーションと同様に気軽に起動できる
  3. GitHubにpushするだけでデプロイ完了
  4. バックエンドはBluesky APIだけなので、独自のサイトを稼働させる必要がない

Native Apps Are Deadなんていう記事もあるようですが、GitHubの公開プロジェクトならGitHub Pagesにデプロイするのは大いにアリなんじゃないでしょうか。

バグレポートと要望はGitHubまで

バグレポートと要望は下記にお願いします。

https://github.com/eyasuyuki/bluespoiler/issues

GitHubで編集を提案

Discussion