cutmail's blog

write the code

AIエージェント向けのスキルパッケージマネージャー「Kata」をリリースした

3行で

  • Claude Code などのエージェントに読ませる skill / command / agent を、YAML 1枚で宣言的に管理する CLI を作りました
  • kata.yml と kata.lock を git に入れておけば、別のマシンでは kata sync 一発で同じ環境が復元されます
  • Go 製の単一バイナリです → https://github.com/cutmail/kata

github.com

~/.claude が秘伝のタレになっていく問題

エージェントに渡すスキルやコマンドは、気づくと ~/.claude/skills/ の下に手作業で溜まっていきます。

GitHub で見つけたスキルを clone してコピーし、自分用に少し書き換え、別のリポジトリから持ってきたコマンドを置き、そのうちどれがどこから来たものか分からなくなる。パッケージマネージャーが登場する前の /usr/local と同じ状態です。

これの何が困るかというと、マシンを移ったときに再現できないことです。新しい PC をセットアップするとき、あるいは仕事用と個人用で環境を揃えたいとき、結局ディレクトリごとコピーすることになります。しかもコピーした先では、元がどのリポジトリのどのコミットだったのか分からないので、上流の更新を取り込むこともできません。

やりたかったのは単純で、~/.claude を「溜まっていく場所」ではなく「宣言する対象」にすることでした。

作ったもの

kata という CLI を作りました。

$ kata sync
+ my-review  skill  ~/.claude/skills/my-review
+ pdf        skill  ~/.claude/skills/pdf
2 created, 0 updated, 0 removed, 0 unchanged

管理用のリポジトリに置くのは、この3つだけです。

my-agent-config/
├── kata.yml     # 何をどこから持ってくるかの宣言
├── kata.lock    # 解決済みのコミット
└── local/       # 自作スキルの実体

これを git に入れておけば、別のマシンでは clone して kata sync するだけで済みます。

使ってみる

インストール

macOS なら Homebrew で入ります。

$ brew install --cask cutmail/tap/kata

GitHub Releases にバイナリも置いてあります(macOS / Linux / Windows、amd64・arm64)。go install でも入ります。

$ go install github.com/cutmail/kata/cmd/kata@latest

セットアップ

管理用のリポジトリを作って kata init します。

$ mkdir my-agent-config && cd my-agent-config
$ git init && kata init
created kata.yml
put your own skills under local/ and register them with 'kata add'

公開リポジトリのスキルを取り込みます。リポジトリ内のサブディレクトリを指定できるので、スキル集から必要なものだけを持ってこられます。

$ kata add anthropics/skills --path skills/pdf --ref main
added pdf (skill) to kata.yml
+ pdf  skill  ~/.claude/skills/pdf
1 created, 0 updated, 0 removed, 0 unchanged

自作のスキルは local/ に置いて登録します。実体が同じリポジトリに入るので、編集するとそのまま git の差分になります。

$ mkdir -p local/skills/my-review && vim local/skills/my-review/SKILL.md
$ kata add ./local/skills/my-review
added my-review (skill) to kata.yml
+ my-review  skill  ~/.claude/skills/my-review
1 created, 0 updated, 0 removed, 0 unchanged

skill / command に加えて agent(サブエージェント定義)も同じ仕組みで管理できます。.md ファイルは type を省略すると command と解釈されるので、agent だけは --type agent を明示します。

既に育ってしまった ~/.claude を取り込む

新規に始める分にはこれでいいのですが、実際に困っていたのはむしろ逆で、すでに手作業で育ってしまった ~/.claude をどう卒業するかでした。そこで kata import を用意しました。

$ kata import --dry-run
dry run: nothing was changed
+ my-review  skill    ~/.claude/skills/my-review  -> ./local/skills/my-review
+ pr         command  ~/.claude/commands/pr.md    -> ./local/commands/pr.md
  skip pdf  managed by kata (declared in ~/dotfiles)
2 to import, 1 skipped

既定では local/ へコピーしてマニフェストに書くだけで、~/.claude 側には一切触れません。元のファイルとコピーが両方存在する状態で確認してから、--adopt を付けたときだけ元を退避して symlink に差し替えます。「取り込んでみたら壊れた」が起きないように、実体を動かす操作は明示的なオプトインにしてあります。

状態の確認

kata list で今の状態が分かります。

$ kata list
NAME       TYPE   STATUS  PROFILES  SOURCE                                            DEST
pdf        skill  linked  all       git+https://github.com/anthropics/skills@f17010c  ~/.claude/skills/pdf
my-review  skill  linked  all       local:./local/skills/my-review                    ~/.claude/skills/my-review

STATUS は linked / copied / missing / drifted / broken / orphan のいずれかになるので、マシンが宣言とズレていないか一目で分かります。CI で使うなら kata status の方が向いています。ズレている項目だけを出力し、何かあれば終了コード 1 を返すので、そのままパイプラインに組み込めます。

あとは commit して push するだけです。

$ git add -A && git commit -m "my agent setup" && git push

別のマシンで

clone して kata sync します。これだけです。

$ git clone <repo> && cd <repo>
$ kata sync
+ my-review  skill  ~/.claude/skills/my-review
+ pdf        skill  ~/.claude/skills/pdf
2 created, 0 updated, 0 removed, 0 unchanged

kata.lock にコミットハッシュが記録されているので、ref: main のような動く参照を書いていても、復元されるのは元のマシンと同じツリーです。

その他のコマンド

  • kata update [name...] — 動く ref を再解決してロックを進める(lock は sync では勝手に動かない、後述)
  • kata doctor — マニフェストとロックの食い違いなど、環境がおかしい理由を説明する
  • kata remove <name> — マニフェストから消して撤去する(kata.yml を直接編集して sync するのと結果は同じ)
  • kata prune — もうどこからも参照されていないキャッシュを掃除する。--apply を付けない限り何も消えない

設計で考えたこと

宣言的にする

コマンドは意図的に sync 一本に寄せました。install と uninstall を別々に持つのではなく、「マニフェストがあるべき状態、sync はそこへ収束させる操作」という Terraform 的なモデルにしています。

なので kata.yml からパッケージを消して sync すれば、配置も自動で撤去されます。同じ sync を何度実行しても結果は変わりません。

$ kata sync
0 created, 0 updated, 0 removed, 2 unchanged

lock でコミットを固定する

ref: main と書いたときに、マシンごとに違うものが降ってくるのでは意味がありません。npm や Bundler と同じで、解決結果を kata.lock に固定します。

resolved:
    - name: pdf
      type: skill
      source: git+https://github.com/anthropics/skills
      ref: main
      commit: f17010c9bb483898c1d9c9f42dde2b3a98889434
      subpath: skills/pdf

sync はロックを正として動くので、上流が進んでも勝手には追随しません。更新したいときだけ kata update を叩く、明示的な操作にしています。

地味に気を使ったのが --profile で絞り込んだときの挙動です。kata sync --profile work で対象を work プロファイルだけに絞っても、選外になったパッケージのロックは消しません。マニフェストに宣言が残っている限りロックも残す、というルールにしないと、「たまたま今回 --profile を付け忘れた」だけで別マシンの再現性が壊れてしまうからです。

配置はデフォルトでは symlink です。一時リンクを作ってから rename で差し替えるので、切り替えは常にアトミックになり、中途半端な状態が残りません。

~/.claude/skills/pdf -> ~/.kata/store/repos/82a901da1b9b-f17010c9bb48/skills/pdf
~/.claude/skills/my-review -> ~/my-agent-config/local/skills/my-review

ただ symlink が作れない、あるいは作りたくない環境もあります。そのために strategy: copy(実体をコピーする)と strategy: auto(symlink が使える環境では link、使えなければ copy に自動で倒す)を用意しました。scope: project と strategy: copy を組み合わせると、配置先の .claude/ をそのままコミットできるので、チームでスキルを共有したいときはこちらが向いています。

copy 戦略は symlink と違って「誰が置いたか」が実体に書かれていないので、state.json に配置時点の内容ダイジェストを記録し、撤去や上書きの前にそれと照合します。一致しなければ「配置後に誰かが編集した」とみなして警告を出し、--force を付けても削除ではなく退避に留めます(壊さない、という不変条件は戦略が変わっても揺らがないようにしてあります)。

CI に windows-latest を含めているのはこの copy/auto 経路を実際に踏むためです。実際、Windows の絶対パス(C:\...)を URL パーサがドライブレターとスキームを取り違えたり、symlink の差し替えで使う MoveFileEx がディレクトリの symlink を置換できなかったりと、macOS や Linux の手元環境だけでは絶対に気づけない不具合を CI で何度か拾いました。

取得元は信用しない

url 取得元は tar.gz / tgz / zip を展開します。ここは攻撃対象になり得る箇所なので、書き込む前に各エントリの展開後パスが展開先ディレクトリの内側に収まっているかを確認し、symlink や特殊ファイルのエントリは拒否しています。サイズの上限判定にはアーカイブが自己申告するサイズを使わず、実際に読み取ったバイト数を見るようにしました。マニフェストの sha256 とロックのダイジェストの照合も、展開の前に行います。

実装

Go で書きました。直接の依存は cobra、yaml.v3、go-git、golang.org/x/sys の4つです。x/sys は state.json を書き換えるときの排他ロック(Unix は flock、Windows は等価な API)にだけ使っています。純 Go 中心の構成なので cgo なしでクロスコンパイルでき、配布は単一バイナリで済みます。

構成は、拡張点になる2つのインターフェースを軸にしています。

cmd/kata/          CLI (cobra)
internal/manifest  kata.yml のパースと検証
internal/lockfile  kata.lock
internal/state     配置実績の記録 (state.json)
internal/store     取得物のキャッシュ
internal/source    取得元とアーカイブの展開 (git, url, local)  ← Fetcher
internal/target    配置先 (Claude Code)                       ← Resolver
internal/linker    配置と撤去 (symlink / コピー)
internal/digest    内容ダイジェスト。所有の判定と取得物の検証に使う
internal/copyfs    ディレクトリ木の複製。import が local/ へ取り込むのに使う
internal/app       全体のオーケストレーション

取得元を増やすなら source.Fetcher を、Cursor など他のエージェントに対応するなら target.Resolver を実装すれば足りるようにしてあります。

~/.kata/store は URL とコミットをキーにした純粋なキャッシュで、消しても sync で復元されます。「消しても壊れないディレクトリ」と「消すと困るディレクトリ」を明確に分けておくと、後から挙動を説明しやすくなって良かったです。

リリースは GoReleaser に任せています。タグを push すると各プラットフォーム分のアーカイブが GitHub Releases に上がり、Homebrew の tap も更新されます。アーカイブには GitHub ネイティブの SLSA build provenance attestation を付けているので、gh attestation verify でタグの付いたコミットからビルドされたことを検証できます。

名前について

kata は武道の「型」です。

再現可能な所作の集合、という意味がそのまま「スキルを型として登録し、どのマシンでも同じ型を再現する」に重なるので気に入っています。短くて打ちやすいのも良いところです。

今後

現時点で skill / command / agent の3種別、git・URL アーカイブ・ローカルの3取得元、symlink / copy / auto の配置戦略、user / project の2スコープ、profiles による絞り込みまでは一通り揃いました。

残っているのはこのあたりです。

  • MCP サーバー設定のマージ
  • Claude Code 以外の Resolver 実装(target.Resolver を実装すれば追加できる想定です)
  • kata.yml の JSON Schema 公開

機能が増えるほど「kata が作っていないものには触らない」という一番の不変条件を壊しやすくなるので、そこは今後も最優先で守っていくつもりです。

おわりに

エージェントに渡す設定は、これから確実に増えていくものだと思っています。増えていくものは、そのうち必ず「どう管理するか」が問題になります。

同じ問題意識を持っている方がいたら、ぜひ触ってみてください。Issue や PR も歓迎です。

github.com

Claude CodeでAndroidアプリをモダンアーキテクチャに移行した話

はじめに

AndroidアプリのURL短縮アプリを、Claude Codeを使ってモダンなアーキテクチャに移行するプロジェクトを進めています。今回は、レガシーなView Systemから最新のJetpack Composeへの移行と、全体的なアーキテクチャの刷新について紹介します。

プロジェクト概要

  • 対象アプリ: Bitly APIを使用したURL短縮Androidアプリ
  • 技術スタック:
    • 旧: Java/Kotlin混在、View System、RxJava
    • 新: Kotlin、Jetpack Compose、Coroutines/Flow、Hilt、Room
  • アーキテクチャ: Clean Architecture(data / domain / presentation層)

実装した主要機能

1. ホーム画面のリニューアル

レガシーなView Systemから、Jetpack Composeを使用したモダンなUIに刷新しました。

Material Design 3への対応

@Composable
fun HomeScreen(
    initialUrl: String? = null,
    onNavigateToSettings: () -> Unit,
    viewModel: HomeViewModel = hiltViewModel()
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(16.dp)
    ) {
        // URL入力フィールド
        OutlinedTextField(
            value = uiState.inputUrl,
            onValueChange = viewModel::onUrlChanged,
            label = { Text(stringResource(R.string.url_input_label)) },
            placeholder = { Text(stringResource(R.string.url_input_placeholder)) },
            isError = uiState.error != null,
            enabled = !uiState.isLoading,
            modifier = Modifier.fillMaxWidth()
        )
        
        // 短縮ボタン
        Button(
            onClick = viewModel::onShortenUrl,
            enabled = uiState.inputUrl.isNotBlank() && !uiState.isLoading,
            modifier = Modifier.fillMaxWidth()
        ) {
            if (uiState.isLoading) {
                CircularProgressIndicator(modifier = Modifier.size(20.dp))
            } else {
                Text(stringResource(R.string.shorten_button))
            }
        }
    }
}

他アプリからの共有対応

// MainActivity
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    
    val initialUrl = when (intent?.action) {
        Intent.ACTION_SEND -> {
            intent.getStringExtra(Intent.EXTRA_TEXT)
        }
        else -> null
    }
    
    setContent {
        URLShortenerTheme {
            MainNavigation(initialUrl = initialUrl)
        }
    }
}

2. 履歴・お気に入り機能の実装

Room データベースを活用した、履歴とお気に入り管理機能を実装しました。

データ層の実装

@Entity(tableName = "urls")
data class UrlEntity(
    @PrimaryKey
    val id: String,
    val longUrl: String,
    val shortUrl: String,
    val title: String? = null,
    val isFavorite: Boolean = false,
    val createdAt: Instant = Instant.now()
)

@Dao
interface UrlDao {
    @Query("SELECT * FROM urls ORDER BY createdAt DESC")
    fun getAllUrls(): Flow<List<UrlEntity>>
    
    @Query("SELECT * FROM urls WHERE isFavorite = 1 ORDER BY createdAt DESC")
    fun getFavoriteUrls(): Flow<List<UrlEntity>>
    
    @Query("""
        SELECT * FROM urls 
        WHERE LOWER(longUrl) LIKE '%' || LOWER(:query) || '%' 
           OR LOWER(COALESCE(title, '')) LIKE '%' || LOWER(:query) || '%' 
           OR LOWER(shortUrl) LIKE '%' || LOWER(:query) || '%' 
        ORDER BY createdAt DESC
    """)
    fun searchUrls(query: String): Flow<List<UrlEntity>>
    
    @Update
    suspend fun updateUrl(url: UrlEntity)
    
    @Delete
    suspend fun deleteUrl(url: UrlEntity)
}

検索機能の実装

検索UIの実装では、Material 3のSearchBarコンポーネントのオーバーレイ問題を解決しました。

@Composable
fun HistoryScreen(
    filter: HistoryFilter = HistoryFilter.ALL,
    viewModel: HistoryViewModel = hiltViewModel()
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    
    Column {
        if (uiState.isSearchActive) {
            // カスタム検索ヘッダー(オーバーレイなし)
            TopAppBar(
                title = {
                    OutlinedTextField(
                        value = uiState.searchQuery,
                        onValueChange = viewModel::onSearchQueryChanged,
                        placeholder = { Text(stringResource(R.string.history_search_hint)) },
                        modifier = Modifier.fillMaxWidth(),
                        singleLine = true
                    )
                },
                navigationIcon = {
                    IconButton(onClick = { viewModel.onSearchActiveChanged(false) }) {
                        Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
                    }
                }
            )
        }
        
        // URL一覧
        LazyColumn {
            items(uiState.urls) { url ->
                UrlItem(
                    url = url,
                    onToggleFavorite = { viewModel.onToggleFavorite(url) },
                    onDelete = { viewModel.onDeleteUrl(url) }
                )
            }
        }
    }
}

3. ボトムナビゲーションの導入

アプリ全体のナビゲーション構造を改善し、主要機能へのアクセスを容易にしました。

@Composable
fun MainNavigation(initialUrl: String? = null) {
    val navController = rememberNavController()
    
    Scaffold(
        bottomBar = {
            NavigationBar {
                val items = listOf(
                    BottomNavItem.Home,
                    BottomNavItem.History,
                    BottomNavItem.Favorites,
                    BottomNavItem.Settings
                )
                
                items.forEach { item ->
                    NavigationBarItem(
                        icon = { Icon(item.icon, contentDescription = null) },
                        label = { Text(stringResource(item.titleRes)) },
                        selected = currentDestination?.hierarchy?.any { 
                            it.route == item.route 
                        } == true,
                        onClick = {
                            navController.navigate(item.route) {
                                popUpTo(navController.graph.findStartDestination().id) {
                                    saveState = true
                                }
                                launchSingleTop = true
                                restoreState = true
                            }
                        }
                    )
                }
            }
        }
    ) { paddingValues ->
        NavHost(
            navController = navController,
            startDestination = BottomNavItem.Home.route,
            modifier = Modifier.padding(paddingValues)
        ) {
            // 各画面の定義
        }
    }
}

4. 初回セットアップフローの実装

ユーザーオンボーディングを改善するため、初回起動時のセットアップフローを実装しました。

@Composable
fun SetupScreen(
    onSetupComplete: () -> Unit,
    onSetupLater: () -> Unit,
    viewModel: SettingsViewModel = hiltViewModel()
) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(24.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        // デフォルトキーで始める(簡単スタート)
        Button(
            onClick = {
                viewModel.onSwitchToDefault()
                viewModel.markSetupCompleted()
                onSetupLater()
            },
            modifier = Modifier.fillMaxWidth()
        ) {
            Text(stringResource(R.string.use_default_key))
        }
        
        // または個人キーを設定
        OutlinedButton(
            onClick = { /* Bitlyサイトへ */ },
            modifier = Modifier.fillMaxWidth()
        ) {
            Icon(Icons.Default.OpenInNew, contentDescription = null)
            Spacer(modifier = Modifier.width(8.dp))
            Text(stringResource(R.string.get_token))
        }
    }
}

5. 設定画面の機能拡張

設定画面にアプリ情報とライセンス表示機能を追加しました。

アーキテクチャの変遷

Before: レガシーアーキテクチャ

旧アーキテクチャ(Single Activity + View System)
┌─────────────────────────────────────┐
│ MainActivity.kt (View System)       │
├─────────────────────────────────────┤
│ • 全ロジックが1つのActivity内      │
│ • findViewById でView取得          │
│ • RxJava による非同期処理          │
│ • 手動でライフサイクル管理          │
│ • グローバル状態管理なし            │
│ • APIトークンハードコーディング    │
└─────────────────────────────────────┘
// 旧実装例
class MainActivity : AppCompatActivity() {
    private lateinit var urlEditText: EditText
    private lateinit var shortenButton: Button
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        
        urlEditText = findViewById(R.id.url_edit_text)
        shortenButton = findViewById(R.id.shorten_button)
        
        shortenButton.setOnClickListener {
            // 直接API呼び出し
            BitlyClient.shorten(urlEditText.text.toString())
                .subscribe(
                    { result -> /* UIを直接更新 */ },
                    { error -> /* エラーハンドリング */ }
                )
        }
    }
}

After: 新モダンアーキテクチャ(Clean Architecture + Jetpack Compose)

graph TB
    subgraph "Presentation Layer"
        subgraph "UI (Jetpack Compose)"
            A[HomeScreen.kt]
            B[HistoryScreen.kt]
            C[SettingsScreen.kt]
            D[LicensesScreen.kt]
            E[Navigation.kt]
        end
        
        subgraph "ViewModels (MVVM)"
            F[HomeViewModel]
            G[HistoryViewModel]
            H[SettingsViewModel]
            I[LicensesViewModel]
        end
    end
    
    subgraph "Domain Layer"
        subgraph "Use Cases"
            J[ShortenUrlUseCase]
            K[GetHistoryUseCase]
            L[ToggleFavoriteUseCase]
            M[SearchUrlsUseCase]
            N[DeleteUrlUseCase]
        end
        
        subgraph "Models & Interfaces"
            O[ShortenedUrl]
            P[UrlShortenerRepository]
        end
    end
    
    subgraph "Data Layer"
        subgraph "Repository Implementation"
            Q[UrlShortenerRepositoryImpl]
        end
        
        subgraph "Data Sources"
            R[Remote: BitlyApiService]
            S[Local: Room Database]
            T[UrlDao]
            U[UrlEntity]
        end
        
        subgraph "Security & Utils"
            V[SecretManager]
            W[EncryptedSharedPreferences]
            X[AppInfoProvider]
        end
    end
    
    %% Dependencies
    A --> F
    B --> G
    C --> H
    D --> I
    
    F --> J
    G --> K
    G --> L
    G --> M
    G --> N
    H --> V
    I --> X
    
    J --> P
    K --> P
    L --> P
    M --> P
    N --> P
    
    P --> Q
    Q --> R
    Q --> S
    S --> T
    T --> U
    
    V --> W
    
    %% Styling
    classDef presentation fill:#e1f5fe
    classDef domain fill:#f3e5f5
    classDef data fill:#e8f5e8
    
    class A,B,C,D,E,F,G,H,I presentation
    class J,K,L,M,N,O,P domain
    class Q,R,S,T,U,V,W,X data

具体的な改善点

1. 依存性注入の導入

// Before: 手動でのオブジェクト生成
class MainActivity {
    private val bitlyClient = BitlyClient()
    private val sharedPreferences = getSharedPreferences("prefs", MODE_PRIVATE)
}

// After: Hiltによる依存性注入
@HiltViewModel
class HomeViewModel @Inject constructor(
    private val shortenUrlUseCase: ShortenUrlUseCase,
    private val secretManager: SecretManager
) : ViewModel()

2. State管理の改善

// Before: 手動でのState管理
class MainActivity {
    private var isLoading = false
    private var currentUrl = ""
    
    private fun updateUI() {
        shortenButton.isEnabled = !isLoading
        // 各Viewを手動で更新
    }
}

// After: Composeでの宣言的UI
@Composable
fun HomeScreen(viewModel: HomeViewModel = hiltViewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    
    // State変更で自動的にUIが更新される
    Button(
        onClick = viewModel::onShortenUrl,
        enabled = !uiState.isLoading
    ) {
        if (uiState.isLoading) {
            CircularProgressIndicator()
        } else {
            Text("Shorten URL")
        }
    }
}

3. データ永続化の改善

// Before: データの永続化なし
// アプリ終了でデータが失われる

// After: Room データベース
@Entity(tableName = "urls")
data class UrlEntity(
    @PrimaryKey val id: String,
    val longUrl: String,
    val shortUrl: String,
    val isFavorite: Boolean = false,
    val createdAt: Instant = Instant.now()
)

@Dao
interface UrlDao {
    @Query("SELECT * FROM urls ORDER BY createdAt DESC")
    fun getAllUrls(): Flow<List<UrlEntity>>
    
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertUrl(url: UrlEntity)
}

実装した詳細機能

1. アプリ情報表示機能

設定画面にアプリの詳細情報を表示する機能を追加しました。

AppInfoProvider の実装

@Singleton
class AppInfoProvider @Inject constructor(
    @ApplicationContext private val context: Context
) {
    fun getVersionName(): String {
        return try {
            val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
            packageInfo.versionName ?: "Unknown"
        } catch (e: PackageManager.NameNotFoundException) {
            "Unknown"
        }
    }
    
    fun getVersionCode(): Long {
        return try {
            val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
                packageInfo.longVersionCode
            } else {
                @Suppress("DEPRECATION")
                packageInfo.versionCode.toLong()
            }
        } catch (e: PackageManager.NameNotFoundException) {
            0L
        }
    }
}

設定画面への統合

// About Section
Card(modifier = Modifier.fillMaxWidth()) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = stringResource(R.string.about),
            style = MaterialTheme.typography.titleMedium
        )
        
        InfoRow(
            label = stringResource(R.string.version_label),
            value = "$versionName ($versionCode)"
        )
        
        InfoRow(
            label = stringResource(R.string.build_type_label),
            value = buildType
        )
    }
}

2. ライブラリライセンス表示機能

OSS利用における適切なライセンス表示のため、使用ライブラリのライセンス一覧画面を実装しました。

データ構造

data class LibraryLicense(
    val name: String,
    val description: String,
    val licenseType: String,
    val licenseText: String
)

展開可能なライセンスカード

@Composable
private fun LicenseCard(license: LibraryLicense) {
    var isExpanded by remember { mutableStateOf(false) }
    
    Card(
        modifier = Modifier.fillMaxWidth(),
        onClick = { isExpanded = !isExpanded }
    ) {
        Column(modifier = Modifier.padding(16.dp)) {
            Text(license.name, style = MaterialTheme.typography.titleMedium)
            Text(license.description, style = MaterialTheme.typography.bodyMedium)
            Text(license.licenseType, style = MaterialTheme.typography.labelMedium)
            
            if (isExpanded) {
                Text(
                    text = license.licenseText,
                    style = MaterialTheme.typography.bodySmall,
                    fontFamily = FontFamily.Monospace
                )
            }
        }
    }
}

Claude Codeの活用ポイント

1. 大規模リファクタリングの計画と実行

Claude Codeは、レガシーコードから最新のアーキテクチャへの移行において、段階的なアプローチを提案してくれました。

移行戦略:
1. 既存コードの分析と問題点の特定
2. Clean Architectureの導入(層分離)
3. View System → Jetpack Compose への段階的移行
4. RxJava → Coroutines/Flow への置き換え
5. 新機能追加による価値向上

2. アーキテクチャパターンの一貫性

既存のコードベースを理解し、適切なアーキテクチャパターンを維持:

  • Repository Pattern: データ層の抽象化
  • MVVM + Clean Architecture: 明確な責務分離
  • Use Case Pattern: ビジネスロジックの独立性
  • Dependency Injection: テスタビリティとメンテナンス性の向上

3. 実践的な問題解決

開発中に遭遇した具体的な問題に対して、実用的な解決策を提案:

SearchBarのオーバーレイ問題

// 問題: Material 3のSearchBarで画面がグレーアウト
// 解決: カスタムTopAppBar + OutlinedTextFieldで代替実装

APIキーモード切り替えの不具合

// 問題: 個人トークン未設定時にモード切り替えが失敗
// 解決: バリデーションタイミングの調整とフォールバック処理

4. コード品質の向上

  • Type-safe Navigation: Compose Navigationの活用
  • State Management: StateFlowとCollectAsStateWithLifecycleの適切な使用
  • Error Handling: 包括的なエラーハンドリング戦略
  • Testing: プレビュー関数の充実によるUI開発効率向上

5. 段階的な実装アプローチ

実装フェーズ:
Phase 1: Core Architecture (Domain + Data Layer)
Phase 2: Basic UI (Jetpack Compose基盤)
Phase 3: Navigation System (BottomNavigation)
Phase 4: Advanced Features (検索、お気に入り)
Phase 5: Polish & UX (設定画面、ライセンス)

開発効率とコード品質の向上

Before (手動開発)

  • 新機能追加: 2-3週間
  • バグ修正: 1-2日
  • アーキテクチャ改善: 困難
  • コードレビュー: 時間がかかる

After (Claude Code活用)

  • 新機能追加: 1-2日
  • バグ修正: 1-2時間
  • アーキテクチャ改善: 段階的に実施可能
  • コードレビュー: 一貫性のあるコードで効率的

学んだベストプラクティス

1. アーキテクチャ設計

  • Clean Architectureの実践的な適用方法
  • Jetpack Composeでの状態管理パターン
  • 依存性注入のベストプラクティス

2. UI/UX設計

3. 開発プロセス

  • 段階的なリファクタリング手法
  • プレビュー駆動開発による効率化
  • 多言語対応の計画的な実装

まとめ

Claude Codeを活用したAndroidアプリのモダン化プロジェクトを通して、以下の成果を得ることができました:

技術的成果

  • アーキテクチャ: レガシーコードからClean Architectureへの完全移行
  • UI Framework: View System → Jetpack Compose への移行完了
  • 非同期処理: RxJava → Coroutines/Flow への置き換え
  • データ管理: Room データベースによる永続化とオフライン対応

ビジネス価値

  • ユーザビリティ: 直感的なナビゲーションと検索機能
  • メンテナンス性: モジュール化されたコードベース
  • 拡張性: 新機能追加が容易なアーキテクチャ
  • 品質: 包括的なエラーハンドリングと多言語対応

開発効率

  • 開発速度: 新機能開発が大幅に高速化
  • コード品質: 一貫したアーキテクチャパターンの維持
  • チーム開発: 明確な責務分離による並行開発の促進

このプロジェクトは現在も継続中で、次のフェーズではテスト自動化とCI/CD パイプラインの改善を予定しています。Claude Codeは、モダンなAndroid開発において非常に強力なパートナーであることが実証されました。

2022年買ってよかったもの - テスラ編

このシリーズも3年目に突入しました。 最近テスラを納車された方も多くなってきたと思うので、やっていきましょう。

フロントマッドフラップ

shop.tesla.com

要は泥除けです。 自分はモバイルサービスの方に取り付けてもらいました。 短いのと長いのがありますが、長いのを取り付けています。

メリットとしては、つけたことによるボディ側面の走行による傷などが付きにくくなりました。 またこれは好みになってしまいますが、見た目を向上するアクセサリ的な役割もあるので、とても気に入っています。

デメリットとしては、全体からみた時に車高が下がるので、機械式駐車場でお断りされる率がかなり上がりました。 ここに関しては覚悟が必要です。

短いのだけにしたら良いかも。

イルミネーテッド キックプレート

shop.tesla.com

こちらも公式のアクセサリ。 少しお高いですが、つけることで暗闇でさりげなく光っておしゃれさを格段に上げてくれます。

ドアを開いた時だけ光るので、バッテリーもしばらくは持つ感じがします。 あんまりゴテゴテしたものをつけたくないけど、少しオリジナリティを付け加えたい人にはおすすめです。

Invisible Foldaway Car Mount with MagSafe Charger

Invisible Foldaway Car Mount with MagSafe Chargerwww.jowua-life.com

センターコンソールに取り付けてMagSafeで充電、取り付けもできるホルダー。 結構使っている人が多いですよね。

助手席に座っている人が快適にドライブ中に動画見たりできるし、使わないときには畳んでしまえるのでとても便利です。

STEM Portable

cado.com

美しいデザインの加湿器。 モデル3のドリンクホルダーに収まります(少しガタガタする)

水のタンクの容量が150mlと結構大きいので、一度水を入れるとしばらく足さなくても使えるのが便利です。 少しお値段張りますが、満足度はとても高いです。

加湿量も3段階で選べるのと、ライトの色も変えることができます。

車の中ってかなり乾燥するので、これが一台あると便利です。


それではまた来年!

(一ヶ月)Twitterやめてみた

自分はわりとTwitterに依存しているタイプだったのだが、 11月上旬に大きめなライフイベントがあったため、これを機にと思い一ヶ月程Twitterをやめてみた。

持っているすべてのスマートフォン、MacなどからもTwitterアプリを削除した。

良かったこと

物事がよりクリアに見えるようになった

Twitterをしているとスクリーンの中のことに思考が占有されがち。

目の前で起きていることに自然と意識が向くようになった(当たり前)

余計なことを考えることがなくなったので、Twitterをしていた時よりも考える時間が増えた。

痩せた

副次的な効果もあると思うが、数値的にも落ちた。

Twitterをしていると、逐一食べ物の写真をあげてしまいがち。

スタバに行く頻度も減った。

その分、写真を撮ることがかなり減った。

良くなかったこと

世間の最新の話題に少し疎くなった

これはそうと言えばそうなんだけど、めちゃめちゃ困ったというほどでもなかった。 ニュースサイトやはてなブックマークなんかは以前と同じように良くみていたので、世の中でどんなことが起きているかは問題なく把握できた。

写真を撮る機会が減った

ここ一ヶ月で撮った写真がほぼない(笑 記録という意味では写真はたくさん残してあると良いですね。

まとめ

Twitterはほどほどに。 目の前で起きていることに目を向けよう。 今後は適度に距離を保ちながら、使っていきたいと思った所存。

2021年買ってよかったもの - テスラ周辺グッズ編

昨年に引き続き今年も買ってよかったシリーズ。 たくさんあるので、テスラの周辺グッズに留めようと思います。

センターコンソール トレイ

shop.tesla.com

アリエクとかでも似たようなものがたくさんありますが、さすが公式。 フロントとリア用で2個ついてきます。 フロント用はスライドすることができて、深い部分に収納したものも簡単に出せるのでかなり便利。 細々したものが整理できるので買ってよかったです。

ガラス ルーフ サンシェード

テスラは天井がガラスルーフなので夏は直射日光で正直暑いです。。 少しでも軽減できるかなと思って、買ってみました。 結果は暑さが軽減できて快適でした。 その反面ガラスルーフ越しに外の景色が見えなくなってしまいますが。。

shop.tesla.com

フットペダルカバー

自分はモデル3のロングレンジモデルなので、パフォーマンスモデルのように金属のフットペダルにしたくて購入。 取り付けがちょっと大変でしたが、かなり満足です。

ja.aliexpress.com

スリムゴミ箱

ゴミ箱は場所を取るのであまり大きいものを置きたくないですが、 これだとフロントシートの後ろに引っ掛けるだけなので、全然場所を取らず良いです。 比較的広がるので、大きめのゴミもわりと入ります。

空気清浄機

車の中って意外と空気が気になるので購入。 花粉症持ちですが、これがあると比較的くしゃみも少なくなりました。 常にONにしています。 カップホルダーにぴったりです。

ディフューザー

車の中はいい匂いがするようにしていたいので、色々探して購入。 アロマオイルを垂らすタイプなので、自分が好きな香りを使えます。 自分はヒノキの香りが好きなので、吉野ヒノキのオイルを使っています。 これもカップホルダーにハマります。 電池とUSBどちらでも対応。

store.at-aroma.com


他にも細かいものとかたくさん買ったけど、書ききれないのでこれくらいで。 また来年もテスラ関係のグッズはたくさん買うと思います。 サイバーホイッスル欲しかった(笑)

Instapaperを改めて使い始めた

あとで読む系のサービスは一貫してPocker (旧Read It Later)を使っていたのだけど、単純に追加した記事を読みたいと思った時に、 Instapaperの記事画面の方が読みやすく、ページが長い場合は自動で分割してくれるところがやっぱり良いと思い、Instapaperに戻ってきた。

しばらくはこれで運用してみようと思う。