THINKLETにインストールしたアプリケーションは、本体のボタンを設定することでボタンで起動することができるようになるけれど、ボタンに割り当てできるのは1つだけなので、複数のアプリケーションを起動できない。使うには事前に設定をしておかねばならない。
CWSを使うと、アプリケーションのインストールや起動の制御ができるようになるのだけれど、個人の通常のアプリ開発でそういった操作をするのも面倒だ。
scrcpyやTHINKLET viewerが使えるような状況ではそれを使ってGUI操作したらいいのだけれど、そうでない場合は、アプリケーションの切り替えができない。

Androidでは、USBデバイスのPIDを制御することで、USBの挿入をトリガーにアプリケーションを起動する方法が提供されている。あるものは使ってみるということで、簡単なPoCプロジェクトを作ってみた。うまく動作している。
PIDを出すマイコン側は前に書いた通り。
Flutterアプリも雑に作ってみたけど、普通に動作した。しかし、同じような3つのアプリを3つ用意するのも面倒なので、これを1つのソースにまとめてみることにした。ビルドオプションで切り替えてビルドできるようにする。
githubにプライベートプロジェクトを作る。
% mkdir thinklet_usbpid_switcher
% cd thinklet_usbpid_switcher
% echo "# thinklet_usbpid_switcher" >> README.md
% git init
% git add README.md
% git commit -m "first commit"
% git branch -M main
% git remote add origin git@github.com:kinneko/thinklet_usbpid_switcher.git
% git push -u origin main
とりあえず、作ったarduinoのスケッチを入れる。
% mkdir arduino
% vi arduino/ATOMS3LITE_USBPID_SWITCH.ino
% git add arduino arduino/ATOMS3LITE_USBPID_SWITCH.ino
% git commit -m "Ardinoプロジェクトを追加"
% git push -u origin main
Androidのディレクトリを追加。Flutterプロジェクトで初期化。
% mkdir android
% cd android
% flutter create --platforms=android --org com.kinneko.pid_test_app pid_test_app
% cd ..
% git add android
% git commit -m "Flutterプロジェクト追加(スケルトン)"
% git push -u origin main
% vi arduino/README.md
% git add arduino/README.md
% git commit -m "README追加"
% git push -u origin main
発声する音声と、画面表示ないようの違う3つのアプリA,B,Cを1つのFlutterソースコードから生成する。productFlavors+フレーバー別device_filter.xmlにして、Flutter 側は --dart-define で表示や音声を切り替えるのがいいかな。
フレーバーごとに表示名(アプリアイコン下の名前)を変えるには、AndroidManifest.xml を @string/app_name 参照にし、build.gradle で各フレーバーに resValue を与えるのが簡単です。
% cd android/pid_test_app
AndroidManifestを「android:label="@string/app_name"」にしておく。
% vi android/app/src/main/AndroidManifest.xml
<application
android:label="@string/app_name"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
...
</application>
build.gradle.ktsをフレーバー対応に書き換え。applicationIdSuffix は、ビルド時に defaultConfig.applicationId の末尾に文字列を付け足す。
% vi android/app/build.gradle.kts
android{}内に追記
// フレーバー
flavorDimensions += "mode"
productFlavors {
create("modeA") {
dimension = "mode"
applicationIdSuffix = ".a"
resValue("string", "PID_A_app", "USB PID A")
}
create("modeB") {
dimension = "mode"
applicationIdSuffix = ".b"
resValue("string", "PID_B_app", "USB PID B")
}
create("modeC") {
dimension = "mode"
applicationIdSuffix = ".c"
resValue("string", "PID_C_app", "USB PID C")
}
}
フレーバー別にデバイスフィルタを定義。
% vi android/app/src/main/res/values/strings.xml
<resources>
<string name="app_name">USB PID</string>
</resources>
% mkdir -p android/app/src/modeA/res/xml/
% vi android/app/src/modeA/res/xml/device_filter.xml
<resources>
<usb-device vendor-id="12936" product-id="16385"/>
</resources>
% mkdir -p android/app/src/modeB/res/xml/
% vi android/app/src/modeB/res/xml/device_filter.xml
<resources>
<usb-device vendor-id="12936" product-id="16386"/>
</resources>
% mkdir -p android/app/src/modeC/res/xml/
% vi android/app/src/modeC/res/xml/device_filter.xml
<resources>
<usb-device vendor-id="12936" product-id="16387"/>
</resources>
AndroidManifest.xmlの追記。USBの許可と、インテントフィルタの設定。
% git diff ./android/app/src/main/AndroidManifest.xml
diff --git a/android/pid_test_app/android/app/src/main/AndroidManifest.xml b/android/pid_test_app/android/app/src/main/AndroidManifest.xml
index e4b5784..b7256b4 100644
--- a/android/pid_test_app/android/app/src/main/AndroidManifest.xml
+++ b/android/pid_test_app/android/app/src/main/AndroidManifest.xml
@@ -1,6 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+ <uses-feature android:name="android.hardware.usb.host" />
<application
- android:label="pid_test_app"
+ android:label="@string/app_name"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
@@ -23,7 +24,11 @@
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
+ <action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
</intent-filter>
+ <meta-data
+ android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
+ android:resource="@xml/device_filter" />
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
音声データの追加。
% vi pubspec.yaml
flutter:
assets:
- assets/voice/
% mkdir -p assets/voice/
cp ~/Download/a.wav ~/Download/b.wav ~/Download/c.wav assets/voice/
% ls assets/voice
a.wav b.wav c.wav
audioplayersライブラリパッケージの追加。
% flutter pub add audioplayers
メインプログラム。
% vi lib/main.dart
import 'package:flutter/material.dart';
import 'package:audioplayers/audioplayers.dart';
const letter = String.fromEnvironment('LETTER', defaultValue: 'A');
const soundPath = String.fromEnvironment('SOUND', defaultValue: 'voice/a.wav');
const bgColorHex = String.fromEnvironment('BG', defaultValue: 'FFDBEAFE');
// 8桁HEXをColorへ
Color _parseHexColor(String hex) {
final s = hex.replaceAll('#', '');
final v = int.tryParse(s, radix: 16);
return v == null ? const Color(0xFFDBEAFE) : Color(v);
}
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
runApp(const App());
}
class App extends StatefulWidget {
const App({super.key});
@override
State<App> createState() => _AppState();
}
class _AppState extends State<App> with WidgetsBindingObserver {
final AudioPlayer _player = AudioPlayer();
DateTime? _lastPlay;
static const _cooldown = Duration(milliseconds: 800); // 連続再生抑止
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_player.setReleaseMode(ReleaseMode.stop);
_play(); // 起動時
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_player.dispose();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_play(); // フロントに戻ったら再生
}
}
Future<void> _play() async {
final now = DateTime.now();
if (_lastPlay != null && now.difference(_lastPlay!) < _cooldown) return;
_lastPlay = now;
try {
await _player.stop(); // 念のため前回を停止
await _player.play(AssetSource(_sound)); // pubspec: assets/voice/
} catch (_) {
// 必要ならログ
}
}
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: Scaffold(
backgroundColor: _parseHexColor(_bgHex),
body: Center(
child: Text(
_letter,
style: const TextStyle(fontSize: 240, fontWeight: FontWeight.bold),
),
),
),
);
}
}
これだとAアプリ以外のパラメータはビルド時に--dart-defineを使ってコマンドラインで渡す必要があってめんどくさいことがわかった。オプションの違いが、コード内ではどこにも定義されていないから、それも運用がどうかと思ったりする。
package_info_plusプラグインパッケージを使って、applicationIdSuffixで付けたパッケージ名をDart側で読み取り、A/B/C を出し分けるようにする。
% flutter pub add package_info_plus
% cat lib/main.dart
import 'package:flutter/material.dart';
import 'package:audioplayers/audioplayers.dart';
import 'package:package_info_plus/package_info_plus.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
runApp(const App());
}
class App extends StatefulWidget {
const App({super.key});
@override
State<App> createState() => _AppState();
}
class _AppState extends State<App> with WidgetsBindingObserver {
final AudioPlayer _player = AudioPlayer();
DateTime? _lastPlay;
static const _cooldown = Duration(milliseconds: 800);
// 出し分け用の状態
String _letter = 'A';
String _sound = 'voice/a.wav';
Color _bg = const Color(0xFFDBEAFE); // 薄い青
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_player.setReleaseMode(ReleaseMode.stop);
_initByPackage(); // パッケージ名でモード決定 → 再生
}
Future<void> _initByPackage() async {
final info = await PackageInfo.fromPlatform();
final id = info.packageName; // 例: com.example.usbauto.a / .b / .c
// applicationIdSuffix に合わせて分岐
if (id.endsWith('.a')) {
_letter = 'A';
_sound = 'voice/a.wav';
_bg = const Color(0xFFDBEAFE); // 薄い青
} else if (id.endsWith('.b')) {
_letter = 'B';
_sound = 'voice/b.wav';
_bg = const Color(0xFFDCFCE7); // 薄い緑
} else if (id.endsWith('.c')) {
_letter = 'C';
_sound = 'voice/c.wav';
_bg = const Color(0xFFFEE2E2); // 薄い赤
} else {
// デフォルト(suffixなし等)
_letter = 'A';
_sound = 'voice/a.wav';
_bg = const Color(0xFFDBEAFE);
}
if (mounted) {
setState(() {});
_play();
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_player.dispose();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_play();
}
}
Future<void> _play() async {
final now = DateTime.now();
if (_lastPlay != null && now.difference(_lastPlay!) < _cooldown) return;
_lastPlay = now;
try {
await _player.stop();
await _player.play(AssetSource(_sound)); // pubspec: assets/voice/
} catch (_) {}
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
backgroundColor: _bg,
body: Center(
child: Text(
_letter,
style: const TextStyle(fontSize: 240, fontWeight: FontWeight.bold),
),
),
),
);
}
}
ビルドのテスト。成功している。
% flutter run --flavor modeA

% flutter run --flavor modeB

% flutter run --flavor modeC

リリースビルドとインストール。
% flutter build apk --flavor modeA --release
% flutter build apk --flavor modeB --release
% flutter build apk --flavor modeC --release
% adb install -r build/app/outputs/flutter-apk/app-modea-release.apk
Performing Streamed Install
Success
% adb install -r build/app/outputs/flutter-apk/app-modeb-release.apk
Performing Streamed Install
Success
% adb install -r build/app/outputs/flutter-apk/app-modec-release.apk
Performing Streamed Install
Success
表示アプリ名がUSB PID固定になってるな... どっか間違っている。

android/app/build.gradle.ktsでandroid:labelが@string/app_nameを見ているなら、各フレーバーで上書くキーも app_name にする必要がある。
書き換え。
productFlavors {
create("modeA") {
dimension = "mode"
applicationIdSuffix = ".a"
resValue("string", "app_name", "USB PID A")
}
create("modeB") {
dimension = "mode"
applicationIdSuffix = ".b"
resValue("string", "app_name", "USB PID B")
}
create("modeC") {
dimension = "mode"
applicationIdSuffix = ".c"
resValue("string", "app_name", "USB PID C")
}
}
3つとも再インストールする。
% flutter build apk --flavor modeA --release
% adb install -r build/app/outputs/flutter-apk/app-modea-release.apk
Performing Streamed Install
Success
できた。

% git add android/pid_test_app/android/app/build.gradle.kts android/pid_test_app/android/app/src/main/AndroidManifest.xml android/pid_test_app/lib/main.dart android/pid_test_app/pubspec.yaml android/pid_test_app/android/app/src/main/res/values/strings.xml android/pid_test_app/android/app/src/modeA/res/xml/device_filter.xml android/pid_test_app/android/app/src/modeB/res/xml/device_filter.xml android/pid_test_app/android/app/src/modeC/res/xml/device_filter.xml android/pid_test_app/assets/voice/
% git commit -m "アプリ本体と設定の追加"
% git push -u origin main
FlutterのREADMEのアップデート。
% vi android/pid_test_app/README.md
---
USB PIDの認識状態に応じて起動するアプリケーションサンプルです。
A,B,Cの3つのアプリケーションを1つのソースコードからビルドします。
```
% flutter build apk --flavor modeA --release
% flutter build apk --flavor modeB --release
% flutter build apk --flavor modeC --release
```
---
% git add android/pid_test_app/README.md
% git commit -m "アプリのREADMEに追記"
% git push -u origin main
% vi README.md
---
- 挿入されたUSB PIDによって起動するアプリケーションを切り替えるPoCです。
---
% git add README.md
% git commit -m "READMEに 追記"
% git push -u origin main
テスト。
% cd ~/Downloads
% git clone git@github.com:kinneko/thinklet_usbpid_switcher.git
Cloning into 'thinklet_usbpid_switcher'...
remote: Enumerating objects: 106, done.
remote: Counting objects: 100% (106/106), done.
remote: Compressing objects: 100% (62/62), done.
remote: Total 106 (delta 11), reused 105 (delta 10), pack-reused 0 (from 0)
Receiving objects: 100% (106/106), 348.79 KiB | 700.00 KiB/s, done.
Resolving deltas: 100% (11/11), done.
% cd thinklet_usbpid_switcher
% cd android/pid_test_app
ビルドとインストールのテストは成功。
連携で動かないよ... 紐付け画面が出ない。
MAIN/LAUNCHER と USB の intent-filter を分離
% vi android/app/src/main/AndroidManifest.xml
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<meta-data
android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
android:resource="@xml/device_filter" />
あいたた、VIDは13000だった。
% adb shell dumpsys usb | grep "My "
/dev/bus/usb/001/002: UsbDevice[mName=/dev/bus/usb/001/002,mVendorId=13000,mProductId=16386,mClass=239,mSubclass=2,mProtocol=1,mManufacturerName=My Company,mProductName=AtomS3 Lite - Mode B,mVersion=2.0,mSerialNumber=B08184952EE4,mConfigurations=[
- Manufacturer 1: My Company Product 2: AtomS3 Lite - Mode B
書き換える。B,Cも同様に変更。
% vi android/app/src/modeA/res/xml/device_filter.xml
<resources>
<usb-device vendor-id="13000" product-id="16385"/>
</resources>
ビルドとインストールし直し。
出た出た。

% git add android/pid_test_app/android/app/src/main/AndroidManifest.xml android/pid_test_app/android/app/src/modeA/res/xml/device_filter.xml android/pid_test_app/android/app/src/modeB/res/xml/device_filter.xml android/pid_test_app/android/app/src/modeC/res/xml/device_filter.xml
% git commit -m "VID10進間違い修正, intent-filter を分離"
% git push -u origin main
アプリのバージョン。変える必要はなかった。
% vi android/pid_test_app/pubspec.yaml
version: 1.0.0+1
リリースを打つ。コミットなしにはlogに残るリリースは打てないので、CHANGELOGを作る。。
% echo "## v1.0.0 - 2025-09-06" >> CHANGELOG.md
% git add CHANGELOG.md
% git commit -m "release: v1.0.0"
% git tag -a v1.0.0 -m "Release v1.0.0"
% git push && git push --tags
なんか、1つのソースから3つのアプリを生成するというのも結構面倒だった。なんでも1つにまとめておきたいというプログラマな病には罹患してしないので、バラバラでソース持っても別にいいような。
あと、プライベートプロジェクトでも、更新するとGithubのグラフが緑になるとは知らなかったわ。
adbの使えないLINKLETでもこの方法でアプリの切り替えはできるのだけど、残念ながらGUIを使う方法がないので、アプリとの紐付けを承認する方法がないのな。無念。
そっちを先に書いてしまったのだけど、zennの会社ブログのほうで簡単な解説は出る予定です。
100円投げ銭 https://kinneko.booth.pm/items/1963720
オリジナル投稿:
thinklet_usbpid_switcher|kinneko|pixivFANBOX
https://kinneko.fanbox.cc/posts/10527266



