diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 36c3e3bb7..14f363777 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,73 +1,76 @@ -plugins { - alias(libs.plugins.android.application) - alias(libs.plugins.kotlin.android) -} - -android { - namespace = "com.shogun.android" - compileSdk = 34 - - defaultConfig { - applicationId = "com.shogun.android" - minSdk = 26 - targetSdk = 34 - versionCode = 2 - versionName = "4.1" - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } - - buildTypes { - release { - isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = "1.8" - } - buildFeatures { - compose = true - } - composeOptions { - kotlinCompilerExtensionVersion = "1.5.14" - } - packaging { - resources { - excludes += "/META-INF/{AL2.0,LGPL2.1}" - } - } -} - -dependencies { - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.lifecycle.runtime.ktx) - implementation(libs.androidx.lifecycle.viewmodel.compose) - implementation(libs.androidx.activity.compose) - implementation(platform(libs.androidx.compose.bom)) - implementation(libs.androidx.ui) - implementation(libs.androidx.ui.graphics) - implementation(libs.androidx.ui.tooling.preview) - implementation(libs.androidx.material3) - implementation(libs.androidx.material.icons.extended) - implementation(libs.androidx.navigation.compose) - implementation(libs.jsch) - implementation(libs.markwon.core) - implementation(libs.markwon.ext.tables) - implementation(libs.accompanist.swiperefresh) - implementation("com.squareup.okhttp3:okhttp:4.12.0") - testImplementation(libs.junit) - androidTestImplementation(libs.androidx.junit) - androidTestImplementation(libs.androidx.espresso.core) - androidTestImplementation(platform(libs.androidx.compose.bom)) - androidTestImplementation(libs.androidx.ui.test.junit4) - debugImplementation(libs.androidx.ui.tooling) - debugImplementation(libs.androidx.ui.test.manifest) -} +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + kotlin("plugin.serialization") version "1.9.24" +} + +android { + namespace = "com.shogun.android" + compileSdk = 34 + + defaultConfig { + applicationId = "com.shogun.android" + minSdk = 26 + targetSdk = 34 + versionCode = 3 + versionName = "4.2" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.14" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(libs.androidx.material.icons.extended) + implementation(libs.androidx.navigation.compose) + implementation(libs.jsch) + implementation(libs.markwon.core) + implementation(libs.markwon.ext.tables) + implementation(libs.accompanist.swiperefresh) + implementation(libs.androidx.security.crypto) + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} diff --git a/android/app/src/main/java/com/shogun/android/MainActivity.kt b/android/app/src/main/java/com/shogun/android/MainActivity.kt index 055c674f4..88ef73de5 100644 --- a/android/app/src/main/java/com/shogun/android/MainActivity.kt +++ b/android/app/src/main/java/com/shogun/android/MainActivity.kt @@ -1,272 +1,354 @@ -package com.shogun.android - -import android.Manifest -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.media.AudioAttributes -import android.media.AudioFocusRequest -import android.media.AudioManager -import android.media.MediaPlayer -import android.net.Uri -import android.os.Build -import android.os.Bundle -import android.widget.Toast -import androidx.core.content.ContextCompat -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.lifecycle.lifecycleScope -import com.shogun.android.ssh.SshManager -import kotlinx.coroutines.launch -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Home -import androidx.compose.material.icons.filled.List -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.Star -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import com.shogun.android.ui.theme.* -import com.shogun.android.util.PrefsKeys -import androidx.compose.ui.unit.sp -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext -import androidx.navigation.NavGraph.Companion.findStartDestination -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.currentBackStackEntryAsState -import androidx.navigation.compose.rememberNavController -import com.shogun.android.ui.AgentsScreen -import com.shogun.android.ui.DashboardScreen -import com.shogun.android.ui.SettingsScreen -import com.shogun.android.ui.ShogunScreen -import com.shogun.android.ui.theme.ShogunTheme - -sealed class Screen(val route: String, val label: String, val icon: ImageVector) { - object Shogun : Screen("shogun", "将軍", Icons.Default.Star) - object Agents : Screen("agents", "エージェント", Icons.Default.List) - object Dashboard : Screen("dashboard", "戦況", Icons.Default.Home) - object Settings : Screen("settings", "設定", Icons.Default.Settings) -} - -val bottomNavItems = listOf( - Screen.Shogun, - Screen.Agents, - Screen.Dashboard, - Screen.Settings -) - -class MainActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - NotificationHelper.initChannels(this) - setContent { - ShogunTheme { - ShogunApp() - } - } - handleShareIntent(intent) - // Only start NtfyService if notification permission is granted (Android 13+) - val hasNotifPerm = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == - PackageManager.PERMISSION_GRANTED - } else true - if (hasNotifPerm && getSharedPreferences(PrefsKeys.PREFS_NAME, MODE_PRIVATE) - .getBoolean(PrefsKeys.NOTIFICATION_ENABLED, true)) { - try { - startForegroundService(Intent(this, NtfyService::class.java)) - } catch (_: Exception) { - // Foreground service start blocked by system — skip silently - } - } - } - - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - handleShareIntent(intent) - } - - private fun handleShareIntent(intent: Intent) { - val imageUris: List = when (intent.action) { - Intent.ACTION_SEND -> { - val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) - } else { - @Suppress("DEPRECATION") - intent.getParcelableExtra(Intent.EXTRA_STREAM) - } - listOfNotNull(uri) - } - Intent.ACTION_SEND_MULTIPLE -> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java) - } else { - @Suppress("DEPRECATION") - intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) - } ?: emptyList() - } - else -> return - } - if (imageUris.isEmpty()) return - - val sshManager = SshManager.getInstance() - if (!sshManager.isConnected()) { - Toast.makeText(this, "❌ SSH未接続。先にアプリを開いて接続してください", Toast.LENGTH_LONG).show() - return - } - - val prefs = getSharedPreferences(PrefsKeys.PREFS_NAME, Context.MODE_PRIVATE) - val projectPath = prefs.getString(PrefsKeys.PROJECT_PATH, "") ?: "" - if (projectPath.isBlank()) { - Toast.makeText(this, "❌ 設定画面でプロジェクトパスを設定してください", Toast.LENGTH_LONG).show() - return - } - val total = imageUris.size - Toast.makeText(this, "転送中... (${total}枚)", Toast.LENGTH_SHORT).show() - lifecycleScope.launch { - var success = 0 - var failed = 0 - for (uri in imageUris) { - sshManager.uploadScreenshot(this@MainActivity, uri, projectPath).fold( - onSuccess = { success++ }, - onFailure = { failed++ } - ) - } - val msg = if (failed == 0) "✅ ${success}枚 転送完了" else "✅ ${success}枚 完了 / ❌ ${failed}枚 失敗" - Toast.makeText(this@MainActivity, msg, Toast.LENGTH_LONG).show() - } - } -} - -@Composable -fun ShogunApp() { - val context = LocalContext.current - val navController = rememberNavController() - val navBackStackEntry by navController.currentBackStackEntryAsState() - val currentRoute = navBackStackEntry?.destination?.route - - // BGM — 3 tracks, tap to cycle: shogun → shogun_reiwa → shogun_ashigirls → OFF → shogun ... - data class BgmTrack(val resId: Int, val label: String) - val tracks = remember { listOf( - BgmTrack(R.raw.shogun, "将軍"), - BgmTrack(R.raw.shogun_reiwa, "令和"), - BgmTrack(R.raw.shogun_ashigirls, "足軽ガールズ") - ) } - var currentTrackIndex by remember { mutableIntStateOf(-1) } // -1 = OFF - var isBgmPlaying by remember { mutableStateOf(false) } - var bgmTrackLabel by remember { mutableStateOf("") } - val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager } - var mediaPlayer by remember { mutableStateOf(null) } - - // AudioFocus - val focusRequest = remember { - AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) - .setAudioAttributes( - AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_GAME) - .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) - .build() - ) - .setOnAudioFocusChangeListener { focusChange -> - when (focusChange) { - AudioManager.AUDIOFOCUS_LOSS -> { - mediaPlayer?.pause() - isBgmPlaying = false - } - } - } - .build() - } - - fun switchTrack(index: Int) { - mediaPlayer?.release() - if (index < 0) { - mediaPlayer = null - audioManager.abandonAudioFocusRequest(focusRequest) - isBgmPlaying = false - currentTrackIndex = -1 - bgmTrackLabel = "" - return - } - val track = tracks[index] - mediaPlayer = MediaPlayer.create(context, track.resId)?.apply { - isLooping = true - setVolume(1.0f, 1.0f) - } - audioManager.requestAudioFocus(focusRequest) - mediaPlayer?.start() - currentTrackIndex = index - isBgmPlaying = true - bgmTrackLabel = track.label - } - - DisposableEffect(Unit) { - onDispose { - audioManager.abandonAudioFocusRequest(focusRequest) - mediaPlayer?.release() - } - } - - Scaffold( - modifier = Modifier.fillMaxSize(), - bottomBar = { - NavigationBar( - containerColor = Shikkoku, - contentColor = Kinpaku, - ) { - bottomNavItems.forEach { screen -> - NavigationBarItem( - icon = { Icon(screen.icon, contentDescription = screen.label) }, - label = { Text(screen.label, fontSize = 10.sp, maxLines = 1) }, - selected = currentRoute == screen.route, - colors = NavigationBarItemDefaults.colors( - selectedIconColor = Kinpaku, - selectedTextColor = Kinpaku, - unselectedIconColor = TextMuted, - unselectedTextColor = TextMuted, - indicatorColor = Sumi, - ), - onClick = { - navController.navigate(screen.route) { - popUpTo(navController.graph.findStartDestination().id) { - saveState = true - } - launchSingleTop = true - restoreState = true - } - } - ) - } - } - } - ) { innerPadding -> - NavHost( - navController = navController, - startDestination = Screen.Shogun.route, - modifier = Modifier.padding(innerPadding) - ) { - composable(Screen.Shogun.route) { - ShogunScreen( - mediaPlayer = mediaPlayer, - isBgmPlaying = isBgmPlaying, - bgmTrackLabel = bgmTrackLabel, - onBgmToggle = { - // Cycle: OFF → track0 → track1 → track2 → OFF - val nextIndex = if (currentTrackIndex < 0) 0 - else if (currentTrackIndex >= tracks.size - 1) -1 - else currentTrackIndex + 1 - switchTrack(nextIndex) - } - ) - } - composable(Screen.Agents.route) { AgentsScreen() } - composable(Screen.Dashboard.route) { DashboardScreen() } - composable(Screen.Settings.route) { SettingsScreen() } - } - } -} +package com.shogun.android + +import android.Manifest +import android.app.Application +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.media.AudioAttributes +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.media.MediaPlayer +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.widget.Toast +import androidx.core.content.ContextCompat +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.lifecycle.lifecycleScope +import com.shogun.android.data.Profile +import com.shogun.android.data.SharedPreferencesProfileRepository +import com.shogun.android.ssh.SshManager +import com.shogun.android.util.EncryptedPrefsProvider +import com.shogun.android.util.PreferencesMigration +import kotlinx.coroutines.launch +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Group +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.List +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import com.shogun.android.ui.AgentsScreen +import com.shogun.android.ui.DashboardScreen +import com.shogun.android.ui.SettingsScreen +import com.shogun.android.ui.ShogunScreen +import com.shogun.android.ui.theme.* +import com.shogun.android.util.PrefsKeys +import com.shogun.android.viewmodel.ProfileViewModel + +sealed class Screen(val route: String, val label: String, val icon: ImageVector) { + object Shogun : Screen("shogun", "将軍", Icons.Default.Star) + object Agents : Screen("agents", "エージェント", Icons.Default.List) + object Dashboard : Screen("dashboard", "戦況", Icons.Default.Home) + object Settings : Screen("settings", "設定", Icons.Default.Settings) +} + +val bottomNavItems = listOf( + Screen.Shogun, + Screen.Agents, + Screen.Dashboard, + Screen.Settings +) + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + PreferencesMigration.migrateIfNeeded(this) + enableEdgeToEdge() + NotificationHelper.initChannels(this) + setContent { + ShogunTheme { + ShogunApp() + } + } + handleShareIntent(intent) + val hasNotifPerm = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED + } else true + if (hasNotifPerm && EncryptedPrefsProvider.getPreferences(this) + .getBoolean(PrefsKeys.NOTIFICATION_ENABLED, true)) { + try { + startForegroundService(Intent(this, NtfyService::class.java)) + } catch (_: Exception) { + // Foreground service start blocked by system — skip silently + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleShareIntent(intent) + } + + private fun handleShareIntent(intent: Intent) { + val imageUris: List = when (intent.action) { + Intent.ACTION_SEND -> { + val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(Intent.EXTRA_STREAM) + } + listOfNotNull(uri) + } + Intent.ACTION_SEND_MULTIPLE -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) + } ?: emptyList() + } + else -> return + } + if (imageUris.isEmpty()) return + + val sshManager = SshManager.getInstance() + if (!sshManager.isConnected()) { + Toast.makeText(this, "❌ SSH未接続。先にアプリを開いて接続してください", Toast.LENGTH_LONG).show() + return + } + + val prefs = EncryptedPrefsProvider.getPreferences(this) + val repository = SharedPreferencesProfileRepository(prefs) + val activeId = repository.getActiveProfileId() + val activeProfile = repository.loadProfiles().let { profiles -> + profiles.find { it.id == activeId } ?: profiles.firstOrNull() + } + // Fallback to legacy prefs key for users who haven't migrated to profiles yet + val projectPath = activeProfile?.projectPath + ?: prefs.getString(PrefsKeys.PROJECT_PATH, "") ?: "" + + if (projectPath.isBlank()) { + Toast.makeText(this, "❌ 設定画面でプロジェクトパスを設定してください", Toast.LENGTH_LONG).show() + return + } + val total = imageUris.size + Toast.makeText(this, "転送中... (${total}枚)", Toast.LENGTH_SHORT).show() + lifecycleScope.launch { + var success = 0 + var failed = 0 + for (uri in imageUris) { + sshManager.uploadScreenshot(this@MainActivity, uri, projectPath).fold( + onSuccess = { success++ }, + onFailure = { failed++ } + ) + } + val msg = if (failed == 0) "✅ ${success}枚 転送完了" else "✅ ${success}枚 完了 / ❌ ${failed}枚 失敗" + Toast.makeText(this@MainActivity, msg, Toast.LENGTH_LONG).show() + } + } +} + +@Composable +fun ShogunApp() { + val context = LocalContext.current + val application = context.applicationContext as Application + val navController = rememberNavController() + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = navBackStackEntry?.destination?.route + + // Profile ViewModel — Activity scoped (hoisted here, shared across screens) + val profileViewModel: ProfileViewModel = viewModel(factory = ProfileViewModel.factory(application)) + val activeProfile by profileViewModel.activeProfile.collectAsState() + val profiles by profileViewModel.profiles.collectAsState() + + // BGM — 3 tracks, tap to cycle: shogun → shogun_reiwa → shogun_ashigirls → OFF → shogun ... + data class BgmTrack(val resId: Int, val label: String) + val tracks = remember { listOf( + BgmTrack(R.raw.shogun, "将軍"), + BgmTrack(R.raw.shogun_reiwa, "令和"), + BgmTrack(R.raw.shogun_ashigirls, "足軽ガールズ") + ) } + var currentTrackIndex by remember { mutableIntStateOf(-1) } // -1 = OFF + var isBgmPlaying by remember { mutableStateOf(false) } + var bgmTrackLabel by remember { mutableStateOf("") } + val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager } + var mediaPlayer by remember { mutableStateOf(null) } + + // AudioFocus + val focusRequest = remember { + AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_GAME) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .build() + ) + .setOnAudioFocusChangeListener { focusChange -> + when (focusChange) { + AudioManager.AUDIOFOCUS_LOSS -> { + mediaPlayer?.pause() + isBgmPlaying = false + } + } + } + .build() + } + + fun switchTrack(index: Int) { + mediaPlayer?.release() + if (index < 0) { + mediaPlayer = null + audioManager.abandonAudioFocusRequest(focusRequest) + isBgmPlaying = false + currentTrackIndex = -1 + bgmTrackLabel = "" + return + } + val track = tracks[index] + mediaPlayer = MediaPlayer.create(context, track.resId)?.apply { + isLooping = true + setVolume(1.0f, 1.0f) + } + audioManager.requestAudioFocus(focusRequest) + mediaPlayer?.start() + currentTrackIndex = index + isBgmPlaying = true + bgmTrackLabel = track.label + } + + DisposableEffect(Unit) { + onDispose { + audioManager.abandonAudioFocusRequest(focusRequest) + mediaPlayer?.release() + } + } + + Scaffold( + modifier = Modifier.fillMaxSize(), + bottomBar = { + var showProfileMenu by remember { mutableStateOf(false) } + NavigationBar( + containerColor = Shikkoku, + contentColor = Kinpaku, + ) { + bottomNavItems.filter { it != Screen.Settings }.forEach { screen -> + NavigationBarItem( + icon = { Icon(screen.icon, contentDescription = screen.label) }, + label = { Text(screen.label, fontSize = 10.sp, maxLines = 1) }, + selected = currentRoute == screen.route, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = Kinpaku, + selectedTextColor = Kinpaku, + unselectedIconColor = TextMuted, + unselectedTextColor = TextMuted, + indicatorColor = Sumi, + ), + onClick = { + navController.navigate(screen.route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } + ) + } + NavigationBarItem( + icon = { + Box { + Icon(Icons.Default.Group, contentDescription = "プロファイル") + DropdownMenu( + expanded = showProfileMenu, + onDismissRequest = { showProfileMenu = false } + ) { + profiles.forEach { profile -> + DropdownMenuItem( + text = { Text(profile.name) }, + onClick = { + profileViewModel.selectProfile(profile.id) + showProfileMenu = false + } + ) + } + } + } + }, + label = { Text(activeProfile?.name ?: "プロファイル", fontSize = 10.sp, maxLines = 1) }, + selected = false, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = Kinpaku, + selectedTextColor = Kinpaku, + unselectedIconColor = TextMuted, + unselectedTextColor = TextMuted, + indicatorColor = Sumi, + ), + onClick = { showProfileMenu = true } + ) + NavigationBarItem( + icon = { Icon(Screen.Settings.icon, contentDescription = Screen.Settings.label) }, + label = { Text(Screen.Settings.label, fontSize = 10.sp, maxLines = 1) }, + selected = currentRoute == Screen.Settings.route, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = Kinpaku, + selectedTextColor = Kinpaku, + unselectedIconColor = TextMuted, + unselectedTextColor = TextMuted, + indicatorColor = Sumi, + ), + onClick = { + navController.navigate(Screen.Settings.route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } + ) + } + } + ) { innerPadding -> + NavHost( + navController = navController, + startDestination = Screen.Shogun.route, + modifier = Modifier.padding(innerPadding) + ) { + composable(Screen.Shogun.route) { + key(activeProfile?.id) { + ShogunScreen( + profileId = activeProfile?.id, + mediaPlayer = mediaPlayer, + isBgmPlaying = isBgmPlaying, + bgmTrackLabel = bgmTrackLabel, + onBgmToggle = { + val nextIndex = if (currentTrackIndex < 0) 0 + else if (currentTrackIndex >= tracks.size - 1) -1 + else currentTrackIndex + 1 + switchTrack(nextIndex) + } + ) + } + } + composable(Screen.Agents.route) { + key(activeProfile?.id) { AgentsScreen(profileId = activeProfile?.id) } + } + composable(Screen.Dashboard.route) { + key(activeProfile?.id) { DashboardScreen(profileId = activeProfile?.id) } + } + composable(Screen.Settings.route) { + SettingsScreen(profileViewModel = profileViewModel) + } + } + } +} diff --git a/android/app/src/main/java/com/shogun/android/NotificationHelper.kt b/android/app/src/main/java/com/shogun/android/NotificationHelper.kt index 8c6233d9f..b1d9f55f6 100644 --- a/android/app/src/main/java/com/shogun/android/NotificationHelper.kt +++ b/android/app/src/main/java/com/shogun/android/NotificationHelper.kt @@ -6,6 +6,7 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent +import com.shogun.android.util.EncryptedPrefsProvider import com.shogun.android.util.PrefsKeys object NotificationHelper { @@ -46,38 +47,38 @@ object NotificationHelper { } fun showNotification(context: Context, message: String, tags: List, title: String) { - val prefs = context.getSharedPreferences(PrefsKeys.PREFS_NAME, Context.MODE_PRIVATE) + val prefs = EncryptedPrefsProvider.getPreferences(context) val channelId: String val prefKey: String when { tags.any { it.contains("cmd_complete") } -> { channelId = CH_CMD_COMPLETE - prefKey = "notify_cmd_complete" + prefKey = PrefsKeys.NOTIFY_CMD_COMPLETE } tags.any { it.contains("failure") } -> { channelId = CH_CMD_FAILURE - prefKey = "notify_cmd_failure" + prefKey = PrefsKeys.NOTIFY_CMD_FAILURE } tags.any { it.contains("action_required") } -> { channelId = CH_ACTION_REQUIRED - prefKey = "notify_action_required" + prefKey = PrefsKeys.NOTIFY_ACTION_REQUIRED } tags.any { it.contains("dashboard") } -> { channelId = CH_DASHBOARD_UPDATE - prefKey = "notify_dashboard_update" + prefKey = PrefsKeys.NOTIFY_DASHBOARD_UPDATE } tags.any { it.contains("streak") } -> { channelId = CH_STREAK_UPDATE - prefKey = "notify_streak_update" + prefKey = PrefsKeys.NOTIFY_STREAK_UPDATE } tags.any { it.contains("agent") } -> { channelId = CH_AGENT_RESPONSE - prefKey = "notify_agent_response" + prefKey = PrefsKeys.NOTIFY_AGENT_RESPONSE } else -> { channelId = CH_CMD_COMPLETE - prefKey = "notify_cmd_complete" + prefKey = PrefsKeys.NOTIFY_CMD_COMPLETE } } diff --git a/android/app/src/main/java/com/shogun/android/NtfyService.kt b/android/app/src/main/java/com/shogun/android/NtfyService.kt index 6bdcf70e9..fe4072ebc 100644 --- a/android/app/src/main/java/com/shogun/android/NtfyService.kt +++ b/android/app/src/main/java/com/shogun/android/NtfyService.kt @@ -21,6 +21,7 @@ import okhttp3.WebSocket import okhttp3.WebSocketListener import org.json.JSONObject import com.shogun.android.util.Defaults +import com.shogun.android.util.EncryptedPrefsProvider import com.shogun.android.util.PrefsKeys import java.util.concurrent.TimeUnit @@ -47,7 +48,7 @@ class NtfyService : Service() { override fun onCreate() { super.onCreate() - val prefs = getSharedPreferences(PrefsKeys.PREFS_NAME, Context.MODE_PRIVATE) + val prefs = EncryptedPrefsProvider.getPreferences(this) if (!prefs.getBoolean(PrefsKeys.NOTIFICATION_ENABLED, true)) { stopSelf() return diff --git a/android/app/src/main/java/com/shogun/android/data/Profile.kt b/android/app/src/main/java/com/shogun/android/data/Profile.kt new file mode 100644 index 000000000..f20b1c12f --- /dev/null +++ b/android/app/src/main/java/com/shogun/android/data/Profile.kt @@ -0,0 +1,20 @@ +package com.shogun.android.data + +import com.shogun.android.util.Defaults +import kotlinx.serialization.Serializable +import java.util.UUID + +@Serializable +data class Profile( + val id: String = UUID.randomUUID().toString(), + val name: String, + val sshHost: String = "", + val sshPort: Int = Defaults.SSH_PORT, + val sshUser: String = "", + val sshKeyPath: String = "", + val sshPassword: String = "", + val projectPath: String = "", + val shogunSession: String = Defaults.SHOGUN_SESSION, + val agentsSession: String = Defaults.AGENTS_SESSION, + val dashboardFileName: String = "dashboard.md" +) diff --git a/android/app/src/main/java/com/shogun/android/data/ProfileRepository.kt b/android/app/src/main/java/com/shogun/android/data/ProfileRepository.kt new file mode 100644 index 000000000..3ad47bdd7 --- /dev/null +++ b/android/app/src/main/java/com/shogun/android/data/ProfileRepository.kt @@ -0,0 +1,54 @@ +package com.shogun.android.data + +import android.content.SharedPreferences +import com.shogun.android.util.PrefsKeys +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +interface ProfileRepository { + fun loadProfiles(): List + fun saveProfiles(profiles: List) + fun addProfile(profile: Profile) + fun updateProfile(profile: Profile) + fun deleteProfile(id: String) + fun getActiveProfileId(): String? + fun setActiveProfileId(id: String) +} + +class SharedPreferencesProfileRepository( + private val prefs: SharedPreferences +) : ProfileRepository { + + private val json = Json { ignoreUnknownKeys = true } + + override fun loadProfiles(): List { + val jsonStr = prefs.getString(PrefsKeys.PROFILES_JSON, null) ?: return emptyList() + return runCatching { json.decodeFromString>(jsonStr) } + .getOrDefault(emptyList()) + } + + override fun saveProfiles(profiles: List) { + prefs.edit() + .putString(PrefsKeys.PROFILES_JSON, json.encodeToString(profiles)) + .apply() + } + + override fun addProfile(profile: Profile) { + saveProfiles(loadProfiles() + profile) + } + + override fun updateProfile(profile: Profile) { + saveProfiles(loadProfiles().map { if (it.id == profile.id) profile else it }) + } + + override fun deleteProfile(id: String) { + saveProfiles(loadProfiles().filter { it.id != id }) + } + + override fun getActiveProfileId(): String? = + prefs.getString(PrefsKeys.ACTIVE_PROFILE_ID, null) + + override fun setActiveProfileId(id: String) { + prefs.edit().putString(PrefsKeys.ACTIVE_PROFILE_ID, id).apply() + } +} diff --git a/android/app/src/main/java/com/shogun/android/ui/AgentsScreen.kt b/android/app/src/main/java/com/shogun/android/ui/AgentsScreen.kt index 72fd47725..89cb27258 100644 --- a/android/app/src/main/java/com/shogun/android/ui/AgentsScreen.kt +++ b/android/app/src/main/java/com/shogun/android/ui/AgentsScreen.kt @@ -1,744 +1,746 @@ -package com.shogun.android.ui - -import android.Manifest -import android.content.Intent -import android.content.pm.PackageManager -import android.os.Bundle -import android.speech.RecognitionListener -import android.speech.RecognizerIntent -import android.speech.SpeechRecognizer -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material.icons.filled.Mic -import androidx.compose.material.icons.filled.Send -import androidx.compose.material.icons.filled.Speed -import androidx.core.content.ContextCompat -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import com.shogun.android.ui.theme.* -import com.shogun.android.util.Defaults -import com.shogun.android.util.PrefsKeys -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.TextRange -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.lifecycle.viewmodel.compose.viewModel -import com.shogun.android.R -import com.shogun.android.viewmodel.AgentsViewModel -import com.shogun.android.viewmodel.PaneInfo - -// ── Rate limit data classes ────────────────────────────────────────────────── -private data class WindowInfo(val percent: Float, val resetStr: String) -private data class ClaudeMaxInfo( - val window5h: WindowInfo?, - val window7d: WindowInfo?, - val sonnet7d: Float?, - val opus7d: Float?, - val todayTokens: String?, - val sessions: Int?, - val messages: Int? -) -private data class CodexQuotaInfo( - val account5h: WindowInfo?, - val account7d: WindowInfo?, - val model5h: WindowInfo?, - val model7d: WindowInfo?, - val modelName: String? -) -private data class CodexEntry(val ashigaru: Int, val percent: Float?) // null = unknown - -private data class RateLimitData( - val claudeMax: ClaudeMaxInfo, - val codexQuota: CodexQuotaInfo, - val codexEntries: List -) - -private fun parseRateLimitResult(text: String): RateLimitData { - val window5h = Regex("""5h window:\s+([\d.]+)%.*\(resets ([^)]+)\)""").find(text)?.let { - WindowInfo(it.groupValues[1].toFloatOrNull() ?: 0f, it.groupValues[2]) - } - val window7d = Regex("""7d window:\s+([\d.]+)%.*\(resets ([^)]+)\)""").find(text)?.let { - WindowInfo(it.groupValues[1].toFloatOrNull() ?: 0f, it.groupValues[2]) - } - val sonnet7d = Regex("""sonnet 7d:\s+([\d.]+)%""").find(text)?.groupValues?.get(1)?.toFloatOrNull() - val opus7d = Regex("""opus 7d:\s+([\d.]+)%""").find(text)?.groupValues?.get(1)?.toFloatOrNull() - val todayTokens = Regex("""Today:\s+([\d,]+) tokens""").find(text)?.groupValues?.get(1) - val sessions = Regex("""Sessions:\s+(\d+)""").find(text)?.groupValues?.get(1)?.toIntOrNull() - val messages = Regex("""Messages:\s+(\d+)""").find(text)?.groupValues?.get(1)?.toIntOrNull() - - val claudeMax = ClaudeMaxInfo(window5h, window7d, sonnet7d, opus7d, todayTokens, sessions, messages) - - // Codex quota: "5h limit: NN% left (resets HH:MM)" — note: "left" not "used" - val quotaRegex5h = Regex("""5h limit:\s+(\d+)% left\s+\(resets ([^)]+)\)""") - val quotaRegex7d = Regex("""Weekly limit:\s+(\d+)% left\s+\(resets ([^)]+)\)""") - val all5h = quotaRegex5h.findAll(text).toList() - val all7d = quotaRegex7d.findAll(text).toList() - - // First match = account-level, second = model-level - val acct5h = all5h.getOrNull(0)?.let { WindowInfo(100f - (it.groupValues[1].toFloatOrNull() ?: 0f), it.groupValues[2]) } - val acct7d = all7d.getOrNull(0)?.let { WindowInfo(100f - (it.groupValues[1].toFloatOrNull() ?: 0f), it.groupValues[2]) } - val mdl5h = all5h.getOrNull(1)?.let { WindowInfo(100f - (it.groupValues[1].toFloatOrNull() ?: 0f), it.groupValues[2]) } - val mdl7d = all7d.getOrNull(1)?.let { WindowInfo(100f - (it.groupValues[1].toFloatOrNull() ?: 0f), it.groupValues[2]) } - val modelName = Regex("""Quota \(([^)]+)\)""").find(text)?.groupValues?.get(1) - - val codexQuota = CodexQuotaInfo(acct5h, acct7d, mdl5h, mdl7d, modelName) - - val codexEntries = mutableListOf() - Regex("""(\d+):(\d+)%""").findAll(text).forEach { m -> - val ash = m.groupValues[1].toIntOrNull() ?: return@forEach - codexEntries.add(CodexEntry(ash, m.groupValues[2].toFloatOrNull())) - } - Regex("""(\d+):\?""").findAll(text).forEach { m -> - val ash = m.groupValues[1].toIntOrNull() ?: return@forEach - if (codexEntries.none { it.ashigaru == ash }) codexEntries.add(CodexEntry(ash, null)) - } - codexEntries.sortBy { it.ashigaru } - return RateLimitData(claudeMax, codexQuota, codexEntries) -} - -private fun rateLimitBarColor(percent: Float): Color = when { - percent >= 80f -> Color(0xFFCC4444) - percent >= 50f -> Kinpaku - else -> Color(0xFF4CAF50) -} - -private fun formatResetTime(resetStr: String): String { - val locale = java.util.Locale.getDefault() - val now = java.time.LocalDateTime.now() - return try { - if (resetStr.contains('T')) { - val ldt = java.time.LocalDateTime.parse(resetStr.take(16)) - val dow = ldt.dayOfWeek.getDisplayName(java.time.format.TextStyle.SHORT, locale) - val timeStr = "${ldt.monthValue}/${ldt.dayOfMonth}($dow) %02d:%02d".format(ldt.hour, ldt.minute) - if (ldt.isBefore(now)) { - "$timeStr にリセット済み" - } else { - "$timeStr にリセット" - } - } else { - val ld = java.time.LocalDate.parse(resetStr) - val today = java.time.LocalDate.now() - val dow = ld.dayOfWeek.getDisplayName(java.time.format.TextStyle.SHORT, locale) - val dateStr = "${ld.monthValue}/${ld.dayOfMonth}($dow)" - if (ld.isBefore(today)) { - "$dateStr にリセット済み" - } else { - "$dateStr にリセット" - } - } - } catch (_: Exception) { - resetStr - } -} - -@Composable -fun AgentsScreen( - viewModel: AgentsViewModel = viewModel() -) { - val context = LocalContext.current - val panes by viewModel.panes.collectAsState() - val errorMessage by viewModel.errorMessage.collectAsState() - val rateLimitLoading by viewModel.rateLimitLoading.collectAsState() - val rateLimitResult by viewModel.rateLimitResult.collectAsState() - - var selectedPaneIndex by remember { mutableStateOf(null) } - var showRateLimitDialog by remember { mutableStateOf(false) } - - // Derive selected pane from live data so it auto-updates - val selectedPane = selectedPaneIndex?.let { idx -> panes.find { it.index == idx } } - - LaunchedEffect(Unit) { - val prefs = context.getSharedPreferences(PrefsKeys.PREFS_NAME, android.content.Context.MODE_PRIVATE) - val host = prefs.getString(PrefsKeys.SSH_HOST, Defaults.SSH_HOST) ?: Defaults.SSH_HOST - val port = prefs.getString(PrefsKeys.SSH_PORT, Defaults.SSH_PORT_STR)?.toIntOrNull() ?: Defaults.SSH_PORT - val user = prefs.getString(PrefsKeys.SSH_USER, "") ?: "" - val keyPath = prefs.getString(PrefsKeys.SSH_KEY_PATH, "") ?: "" - val password = prefs.getString(PrefsKeys.SSH_PASSWORD, "") ?: "" - viewModel.connect(host, port, user, keyPath, password) - } - - // Pause refresh when app is in background - val lifecycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifecycleOwner) { - val observer = LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_RESUME -> viewModel.resumeRefresh() - Lifecycle.Event.ON_PAUSE -> viewModel.pauseRefresh() - else -> {} - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } - } - - if (selectedPane != null) { - // Full screen pane detail — always reads from live panes list - PaneFullScreen( - pane = selectedPane, - onBack = { selectedPaneIndex = null }, - onSendCommand = { cmd -> - viewModel.sendCommandToPane(selectedPane.index, cmd) - }, - onRefresh = { viewModel.refreshAllPanes() } - ) - } else { - // Grid view - Box( - modifier = Modifier - .fillMaxSize() - .background(Shikkoku) - ) { - Image( - painter = painterResource(R.drawable.bg_agents), - contentDescription = null, - contentScale = ContentScale.Crop, - alpha = 0.55f, - modifier = Modifier.fillMaxSize() - ) - Column(modifier = Modifier.fillMaxSize()) { - if (errorMessage != null) { - SelectionContainer { - Text( - text = "エラー: $errorMessage", - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(8.dp) - ) - } - } - - LazyVerticalGrid( - columns = GridCells.Fixed(2), - modifier = Modifier.weight(1f).fillMaxWidth(), - contentPadding = PaddingValues(start = 8.dp, end = 8.dp, top = 8.dp, bottom = 72.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - items(panes) { pane -> - PaneCard( - pane = pane, - onClick = { selectedPaneIndex = pane.index } - ) - } - } - } - - // Rate limit check button (bottom-right) - FloatingActionButton( - onClick = { - showRateLimitDialog = true - viewModel.execRateLimitCheck() - }, - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(16.dp) - .size(48.dp), - containerColor = Sumi, - contentColor = Kinpaku - ) { - Icon( - imageVector = Icons.Default.Speed, - contentDescription = "使用量", - modifier = Modifier.size(24.dp) - ) - } - } - - // Rate limit dialog - if (showRateLimitDialog) { - var showRawText by remember { mutableStateOf(false) } - AlertDialog( - onDismissRequest = { - showRateLimitDialog = false - viewModel.clearRateLimitResult() - }, - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - SelectionContainer { - Text("Rate Limit Check", color = Kinpaku) - } - if (!rateLimitLoading && rateLimitResult != null) { - TextButton(onClick = { showRawText = !showRawText }) { - Text(if (showRawText) "UI" else "Raw", color = Color(0xFF888888), fontSize = 11.sp) - } - } - } - }, - text = { - if (rateLimitLoading) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(color = Kinpaku) - } - } else if (showRawText) { - SelectionContainer { - Text( - text = rateLimitResult ?: "", - color = Zouge, - fontSize = 10.sp, - fontFamily = FontFamily.Monospace, - modifier = Modifier.verticalScroll(rememberScrollState()) - ) - } - } else { - RateLimitContent(rawText = rateLimitResult ?: "") - } - }, - confirmButton = { - TextButton(onClick = { - showRateLimitDialog = false - viewModel.clearRateLimitResult() - }) { - SelectionContainer { - Text("閉じる", color = Kinpaku) - } - } - }, - containerColor = Sumi, - titleContentColor = Kinpaku, - textContentColor = Zouge - ) - } - } -} - -@Composable -fun PaneCard( - pane: PaneInfo, - onClick: () -> Unit -) { - Card( - modifier = Modifier - .fillMaxWidth() - .height(160.dp) - .clickable(onClick = onClick), - colors = CardDefaults.cardColors(containerColor = Color(0x802D2D2D)) - ) { - SelectionContainer { - Column(modifier = Modifier.padding(8.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = pane.agentId.ifBlank { "pane${pane.index}" }, - color = Kinpaku, - fontSize = 12.sp, - fontFamily = FontFamily.Monospace - ) - if (pane.modelName.isNotBlank()) { - Text( - text = " (${pane.modelName})", - color = Color(0xFFAAAAAA), - fontSize = 10.sp, - fontFamily = FontFamily.Monospace - ) - } - } - Spacer(modifier = Modifier.height(4.dp)) - val tailLines = remember(pane.content) { - pane.content.lines().dropLastWhile { it.isBlank() }.takeLast(10).joinToString("\n") - } - Text( - text = parseAnsiColors(tailLines), - color = Zouge, - fontSize = 10.sp, - fontFamily = FontFamily.Monospace, - maxLines = 10, - overflow = TextOverflow.Ellipsis - ) - } - } - } -} - -@Composable -fun PaneFullScreen( - pane: PaneInfo, - onBack: () -> Unit, - onSendCommand: (String) -> Unit, - onRefresh: () -> Unit -) { - val context = LocalContext.current - var commandTextValue by remember { mutableStateOf(TextFieldValue("")) } - var isListening by remember { mutableStateOf(false) } - val speechRecognizer = remember { - if (SpeechRecognizer.isRecognitionAvailable(context)) - SpeechRecognizer.createSpeechRecognizer(context) - else null - } - val horizontalScrollState = rememberScrollState() - val verticalScrollState = rememberScrollState() - val parsedPaneContent = remember(pane.content) { parseAnsiColors(pane.content) } - - DisposableEffect(Unit) { - onDispose { speechRecognizer?.destroy() } - } - - val permissionLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission() - ) { granted -> - if (granted && speechRecognizer != null) { - startContinuousListening(speechRecognizer, { isListening }) { result -> - val newText = if (commandTextValue.text.isEmpty()) result else "${commandTextValue.text} $result" - commandTextValue = TextFieldValue(text = newText, selection = TextRange(newText.length)) - } - isListening = true - } - } - - // Keep following the newest output while preserving text-selection support. - LaunchedEffect(pane.content, verticalScrollState.maxValue) { - verticalScrollState.scrollTo(verticalScrollState.maxValue) - } - - Box( - modifier = Modifier - .fillMaxSize() - .background(Shikkoku) - ) { - Image( - painter = painterResource(R.drawable.bg_agents), - contentDescription = null, - contentScale = ContentScale.Crop, - alpha = 0.55f, - modifier = Modifier.fillMaxSize() - ) - Column( - modifier = Modifier.fillMaxSize() - ) { - // Top bar with agent name and back button - Row( - modifier = Modifier - .fillMaxWidth() - .background(Color(0x802D2D2D)) - .padding(horizontal = 8.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - IconButton(onClick = onBack) { - Icon( - imageVector = Icons.Default.ArrowBack, - contentDescription = "戻る", - tint = Kinpaku - ) - } - SelectionContainer { - Row( - modifier = Modifier.weight(1f), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = pane.agentId.ifBlank { "pane${pane.index}" }, - color = Kinpaku, - fontSize = 16.sp, - fontFamily = FontFamily.Monospace - ) - if (pane.modelName.isNotBlank()) { - Text( - text = " (${pane.modelName})", - color = Color(0xFFAAAAAA), - fontSize = 12.sp, - fontFamily = FontFamily.Monospace - ) - } - } - } - } - - // Full screen pane content - Box( - modifier = Modifier - .weight(1f) - .fillMaxWidth() - .horizontalScroll(horizontalScrollState) - ) { - SelectionContainer { - Text( - text = parsedPaneContent, - color = Zouge, - fontFamily = FontFamily.Monospace, - fontSize = 13.sp, - softWrap = false, - modifier = Modifier - .fillMaxHeight() - .verticalScroll(verticalScrollState) - .padding(horizontal = 8.dp, vertical = 4.dp) - ) - } - } // Box (horizontal scroll) - - // Special keys bar - SpecialKeysRow(onSendKey = { onSendCommand(it) }) - - // Command input at bottom - Row( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedTextField( - value = commandTextValue, - onValueChange = { commandTextValue = it }, - modifier = Modifier.weight(1f), - placeholder = { Text("コマンドを入力") }, - singleLine = true - ) - Spacer(modifier = Modifier.width(4.dp)) - // Voice input button - IconButton( - onClick = { - if (speechRecognizer == null) return@IconButton - if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) - == PackageManager.PERMISSION_GRANTED - ) { - if (isListening) { - speechRecognizer.cancel() - isListening = false - } else { - startContinuousListening(speechRecognizer, { isListening }) { result -> - val newText = if (commandTextValue.text.isEmpty()) result else "${commandTextValue.text} $result" - commandTextValue = TextFieldValue(text = newText, selection = TextRange(newText.length)) - } - isListening = true - } - } else { - permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) - } - } - ) { - Icon( - imageVector = Icons.Default.Mic, - contentDescription = "音声入力", - tint = if (isListening) Kurenai else Kinpaku - ) - } - Spacer(modifier = Modifier.width(4.dp)) - IconButton( - onClick = { - if (commandTextValue.text.isNotBlank()) { - onSendCommand(commandTextValue.text) - commandTextValue = TextFieldValue("") - } - }, - enabled = commandTextValue.text.isNotBlank() && !isListening - ) { - Icon( - imageVector = Icons.Default.Send, - contentDescription = "送信", - tint = if (commandTextValue.text.isNotBlank() && !isListening) Kinpaku else TextMuted - ) - } - } - } // Column - } // Box -} - -// ── Rate Limit UI ───────────────────────────────────────────────────────────── -@Composable -private fun RateLimitContent(rawText: String) { - val data = remember(rawText) { parseRateLimitResult(rawText) } - val claudeMax = data.claudeMax - val codexQuota = data.codexQuota - val codexEntries = data.codexEntries - val hasAnyData = claudeMax.window5h != null || claudeMax.window7d != null || - codexQuota.account5h != null || codexQuota.model5h != null || codexEntries.isNotEmpty() - SelectionContainer { - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - if (!hasAnyData && rawText.isNotBlank()) { - Text("パース失敗 — Raw出力:", color = Color(0xFFCC4444), fontSize = 11.sp) - Text(rawText, color = Zouge, fontSize = 10.sp, fontFamily = FontFamily.Monospace) - } - - // ── Claude Max section ── - Text("Claude Max", color = Kinpaku, fontSize = 13.sp, fontFamily = FontFamily.Monospace) - Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(Color(0xFF555555))) - - claudeMax.window5h?.let { w -> - val color = rateLimitBarColor(w.percent) - Text("5時間枠", color = Zouge, fontSize = 12.sp) - LinearProgressIndicator( - progress = { w.percent / 100f }, - modifier = Modifier.fillMaxWidth(), - color = color, - trackColor = Color(0xFF444444) - ) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text("${w.percent}%", color = color, fontSize = 11.sp) - Text(formatResetTime(w.resetStr), color = Color(0xFF888888), fontSize = 11.sp) - } - } - - claudeMax.window7d?.let { w -> - val color = rateLimitBarColor(w.percent) - Text("7日枠", color = Zouge, fontSize = 12.sp) - LinearProgressIndicator( - progress = { w.percent / 100f }, - modifier = Modifier.fillMaxWidth(), - color = color, - trackColor = Color(0xFF444444) - ) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text("${w.percent}%", color = color, fontSize = 11.sp) - Text(formatResetTime(w.resetStr), color = Color(0xFF888888), fontSize = 11.sp) - } - if (claudeMax.sonnet7d != null || claudeMax.opus7d != null) { - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - claudeMax.sonnet7d?.let { Text("Sonnet: ${it}%", color = Color(0xFF888888), fontSize = 11.sp) } - claudeMax.opus7d?.let { Text("Opus: ${it}%", color = Color(0xFF888888), fontSize = 11.sp) } - } - } - } - - claudeMax.todayTokens?.let { tokens -> - Text("本日トークン", color = Zouge, fontSize = 12.sp) - Text(tokens, color = Kinpaku, fontSize = 15.sp, fontFamily = FontFamily.Monospace) - } - - if (claudeMax.sessions != null || claudeMax.messages != null) { - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - claudeMax.sessions?.let { Text("セッション: $it", color = Color(0xFF888888), fontSize = 11.sp) } - claudeMax.messages?.let { Text("メッセージ: $it", color = Color(0xFF888888), fontSize = 11.sp) } - } - } - - // ── Codex Quota section ── - if (codexQuota.account5h != null || codexQuota.model5h != null) { - Spacer(modifier = Modifier.height(4.dp)) - Text("ChatGPT Pro クォータ", color = Kinpaku, fontSize = 13.sp, fontFamily = FontFamily.Monospace) - Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(Color(0xFF555555))) - - // Account-level quota - codexQuota.account5h?.let { w -> - val color = rateLimitBarColor(w.percent) - Text("Account 5時間枠", color = Zouge, fontSize = 12.sp) - LinearProgressIndicator( - progress = { w.percent / 100f }, - modifier = Modifier.fillMaxWidth(), - color = color, - trackColor = Color(0xFF444444) - ) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text("${w.percent.toInt()}% used", color = color, fontSize = 11.sp) - Text("resets ${w.resetStr}", color = Color(0xFF888888), fontSize = 11.sp) - } - } - codexQuota.account7d?.let { w -> - val color = rateLimitBarColor(w.percent) - Text("Account Weekly", color = Zouge, fontSize = 12.sp) - LinearProgressIndicator( - progress = { w.percent / 100f }, - modifier = Modifier.fillMaxWidth(), - color = color, - trackColor = Color(0xFF444444) - ) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text("${w.percent.toInt()}% used", color = color, fontSize = 11.sp) - Text("resets ${w.resetStr}", color = Color(0xFF888888), fontSize = 11.sp) - } - } - - // Model-level quota - if (codexQuota.model5h != null) { - val label = codexQuota.modelName ?: "Model" - codexQuota.model5h.let { w -> - val color = rateLimitBarColor(w.percent) - Text("$label 5時間枠", color = Zouge, fontSize = 12.sp) - LinearProgressIndicator( - progress = { w.percent / 100f }, - modifier = Modifier.fillMaxWidth(), - color = color, - trackColor = Color(0xFF444444) - ) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text("${w.percent.toInt()}% used", color = color, fontSize = 11.sp) - Text("resets ${w.resetStr}", color = Color(0xFF888888), fontSize = 11.sp) - } - } - codexQuota.model7d?.let { w -> - val color = rateLimitBarColor(w.percent) - Text("$label Weekly", color = Zouge, fontSize = 12.sp) - LinearProgressIndicator( - progress = { w.percent / 100f }, - modifier = Modifier.fillMaxWidth(), - color = color, - trackColor = Color(0xFF444444) - ) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text("${w.percent.toInt()}% used", color = color, fontSize = 11.sp) - Text("resets ${w.resetStr}", color = Color(0xFF888888), fontSize = 11.sp) - } - } - } - } - - // ── Codex context per agent ── - if (codexEntries.isNotEmpty()) { - Spacer(modifier = Modifier.height(4.dp)) - Text("Codex5.3 コンテキスト", color = Kinpaku, fontSize = 13.sp, fontFamily = FontFamily.Monospace) - Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(Color(0xFF555555))) - - codexEntries.forEach { entry -> - val label = "ash${entry.ashigaru}" - val pct = entry.percent - if (pct != null) { - val usedPct = 100f - pct - val color = rateLimitBarColor(usedPct) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text(label, color = Zouge, fontSize = 11.sp, modifier = Modifier.width(40.dp)) - LinearProgressIndicator( - progress = { usedPct / 100f }, - modifier = Modifier.weight(1f), - color = color, - trackColor = Color(0xFF444444) - ) - Text("${pct.toInt()}%", color = color, fontSize = 11.sp) - } - } else { - Row(modifier = Modifier.fillMaxWidth()) { - Text("$label: ?", color = Color(0xFF888888), fontSize = 11.sp) - } - } - } - } - } - } -} +package com.shogun.android.ui + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Bundle +import android.speech.RecognitionListener +import android.speech.RecognizerIntent +import android.speech.SpeechRecognizer +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.Send +import androidx.compose.material.icons.filled.Speed +import androidx.core.content.ContextCompat +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.shogun.android.ui.theme.* +import com.shogun.android.util.Defaults +import com.shogun.android.util.EncryptedPrefsProvider +import com.shogun.android.util.PrefsKeys +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.shogun.android.R +import com.shogun.android.viewmodel.AgentsViewModel +import com.shogun.android.viewmodel.PaneInfo + +// ── Rate limit data classes ────────────────────────────────────────────────── +private data class WindowInfo(val percent: Float, val resetStr: String) +private data class ClaudeMaxInfo( + val window5h: WindowInfo?, + val window7d: WindowInfo?, + val sonnet7d: Float?, + val opus7d: Float?, + val todayTokens: String?, + val sessions: Int?, + val messages: Int? +) +private data class CodexQuotaInfo( + val account5h: WindowInfo?, + val account7d: WindowInfo?, + val model5h: WindowInfo?, + val model7d: WindowInfo?, + val modelName: String? +) +private data class CodexEntry(val ashigaru: Int, val percent: Float?) // null = unknown + +private data class RateLimitData( + val claudeMax: ClaudeMaxInfo, + val codexQuota: CodexQuotaInfo, + val codexEntries: List +) + +private fun parseRateLimitResult(text: String): RateLimitData { + val window5h = Regex("""5h window:\s+([\d.]+)%.*\(resets ([^)]+)\)""").find(text)?.let { + WindowInfo(it.groupValues[1].toFloatOrNull() ?: 0f, it.groupValues[2]) + } + val window7d = Regex("""7d window:\s+([\d.]+)%.*\(resets ([^)]+)\)""").find(text)?.let { + WindowInfo(it.groupValues[1].toFloatOrNull() ?: 0f, it.groupValues[2]) + } + val sonnet7d = Regex("""sonnet 7d:\s+([\d.]+)%""").find(text)?.groupValues?.get(1)?.toFloatOrNull() + val opus7d = Regex("""opus 7d:\s+([\d.]+)%""").find(text)?.groupValues?.get(1)?.toFloatOrNull() + val todayTokens = Regex("""Today:\s+([\d,]+) tokens""").find(text)?.groupValues?.get(1) + val sessions = Regex("""Sessions:\s+(\d+)""").find(text)?.groupValues?.get(1)?.toIntOrNull() + val messages = Regex("""Messages:\s+(\d+)""").find(text)?.groupValues?.get(1)?.toIntOrNull() + + val claudeMax = ClaudeMaxInfo(window5h, window7d, sonnet7d, opus7d, todayTokens, sessions, messages) + + // Codex quota: "5h limit: NN% left (resets HH:MM)" — note: "left" not "used" + val quotaRegex5h = Regex("""5h limit:\s+(\d+)% left\s+\(resets ([^)]+)\)""") + val quotaRegex7d = Regex("""Weekly limit:\s+(\d+)% left\s+\(resets ([^)]+)\)""") + val all5h = quotaRegex5h.findAll(text).toList() + val all7d = quotaRegex7d.findAll(text).toList() + + // First match = account-level, second = model-level + val acct5h = all5h.getOrNull(0)?.let { WindowInfo(100f - (it.groupValues[1].toFloatOrNull() ?: 0f), it.groupValues[2]) } + val acct7d = all7d.getOrNull(0)?.let { WindowInfo(100f - (it.groupValues[1].toFloatOrNull() ?: 0f), it.groupValues[2]) } + val mdl5h = all5h.getOrNull(1)?.let { WindowInfo(100f - (it.groupValues[1].toFloatOrNull() ?: 0f), it.groupValues[2]) } + val mdl7d = all7d.getOrNull(1)?.let { WindowInfo(100f - (it.groupValues[1].toFloatOrNull() ?: 0f), it.groupValues[2]) } + val modelName = Regex("""Quota \(([^)]+)\)""").find(text)?.groupValues?.get(1) + + val codexQuota = CodexQuotaInfo(acct5h, acct7d, mdl5h, mdl7d, modelName) + + val codexEntries = mutableListOf() + Regex("""(\d+):(\d+)%""").findAll(text).forEach { m -> + val ash = m.groupValues[1].toIntOrNull() ?: return@forEach + codexEntries.add(CodexEntry(ash, m.groupValues[2].toFloatOrNull())) + } + Regex("""(\d+):\?""").findAll(text).forEach { m -> + val ash = m.groupValues[1].toIntOrNull() ?: return@forEach + if (codexEntries.none { it.ashigaru == ash }) codexEntries.add(CodexEntry(ash, null)) + } + codexEntries.sortBy { it.ashigaru } + return RateLimitData(claudeMax, codexQuota, codexEntries) +} + +private fun rateLimitBarColor(percent: Float): Color = when { + percent >= 80f -> Color(0xFFCC4444) + percent >= 50f -> Kinpaku + else -> Color(0xFF4CAF50) +} + +private fun formatResetTime(resetStr: String): String { + val locale = java.util.Locale.getDefault() + val now = java.time.LocalDateTime.now() + return try { + if (resetStr.contains('T')) { + val ldt = java.time.LocalDateTime.parse(resetStr.take(16)) + val dow = ldt.dayOfWeek.getDisplayName(java.time.format.TextStyle.SHORT, locale) + val timeStr = "${ldt.monthValue}/${ldt.dayOfMonth}($dow) %02d:%02d".format(ldt.hour, ldt.minute) + if (ldt.isBefore(now)) { + "$timeStr にリセット済み" + } else { + "$timeStr にリセット" + } + } else { + val ld = java.time.LocalDate.parse(resetStr) + val today = java.time.LocalDate.now() + val dow = ld.dayOfWeek.getDisplayName(java.time.format.TextStyle.SHORT, locale) + val dateStr = "${ld.monthValue}/${ld.dayOfMonth}($dow)" + if (ld.isBefore(today)) { + "$dateStr にリセット済み" + } else { + "$dateStr にリセット" + } + } + } catch (_: Exception) { + resetStr + } +} + +@Composable +fun AgentsScreen( + profileId: String? = null, + viewModel: AgentsViewModel = viewModel() +) { + val context = LocalContext.current + val panes by viewModel.panes.collectAsState() + val errorMessage by viewModel.errorMessage.collectAsState() + val rateLimitLoading by viewModel.rateLimitLoading.collectAsState() + val rateLimitResult by viewModel.rateLimitResult.collectAsState() + + var selectedPaneIndex by remember { mutableStateOf(null) } + var showRateLimitDialog by remember { mutableStateOf(false) } + + // Derive selected pane from live data so it auto-updates + val selectedPane = selectedPaneIndex?.let { idx -> panes.find { it.index == idx } } + + LaunchedEffect(profileId) { + val prefs = EncryptedPrefsProvider.getPreferences(context) + val host = prefs.getString(PrefsKeys.SSH_HOST, Defaults.SSH_HOST) ?: Defaults.SSH_HOST + val port = prefs.getString(PrefsKeys.SSH_PORT, Defaults.SSH_PORT_STR)?.toIntOrNull() ?: Defaults.SSH_PORT + val user = prefs.getString(PrefsKeys.SSH_USER, "") ?: "" + val keyPath = prefs.getString(PrefsKeys.SSH_KEY_PATH, "") ?: "" + val password = prefs.getString(PrefsKeys.SSH_PASSWORD, "") ?: "" + viewModel.connect(host, port, user, keyPath, password) + } + + // Pause refresh when app is in background + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> viewModel.resumeRefresh() + Lifecycle.Event.ON_PAUSE -> viewModel.pauseRefresh() + else -> {} + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + if (selectedPane != null) { + // Full screen pane detail — always reads from live panes list + PaneFullScreen( + pane = selectedPane, + onBack = { selectedPaneIndex = null }, + onSendCommand = { cmd -> + viewModel.sendCommandToPane(selectedPane.index, cmd) + }, + onRefresh = { viewModel.refreshAllPanes() } + ) + } else { + // Grid view + Box( + modifier = Modifier + .fillMaxSize() + .background(Shikkoku) + ) { + Image( + painter = painterResource(R.drawable.bg_agents), + contentDescription = null, + contentScale = ContentScale.Crop, + alpha = 0.55f, + modifier = Modifier.fillMaxSize() + ) + Column(modifier = Modifier.fillMaxSize()) { + if (errorMessage != null) { + SelectionContainer { + Text( + text = "エラー: $errorMessage", + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(8.dp) + ) + } + } + + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier.weight(1f).fillMaxWidth(), + contentPadding = PaddingValues(start = 8.dp, end = 8.dp, top = 8.dp, bottom = 72.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(panes) { pane -> + PaneCard( + pane = pane, + onClick = { selectedPaneIndex = pane.index } + ) + } + } + } + + // Rate limit check button (bottom-right) + FloatingActionButton( + onClick = { + showRateLimitDialog = true + viewModel.execRateLimitCheck() + }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp) + .size(48.dp), + containerColor = Sumi, + contentColor = Kinpaku + ) { + Icon( + imageVector = Icons.Default.Speed, + contentDescription = "使用量", + modifier = Modifier.size(24.dp) + ) + } + } + + // Rate limit dialog + if (showRateLimitDialog) { + var showRawText by remember { mutableStateOf(false) } + AlertDialog( + onDismissRequest = { + showRateLimitDialog = false + viewModel.clearRateLimitResult() + }, + title = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + SelectionContainer { + Text("Rate Limit Check", color = Kinpaku) + } + if (!rateLimitLoading && rateLimitResult != null) { + TextButton(onClick = { showRawText = !showRawText }) { + Text(if (showRawText) "UI" else "Raw", color = Color(0xFF888888), fontSize = 11.sp) + } + } + } + }, + text = { + if (rateLimitLoading) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = Kinpaku) + } + } else if (showRawText) { + SelectionContainer { + Text( + text = rateLimitResult ?: "", + color = Zouge, + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + modifier = Modifier.verticalScroll(rememberScrollState()) + ) + } + } else { + RateLimitContent(rawText = rateLimitResult ?: "") + } + }, + confirmButton = { + TextButton(onClick = { + showRateLimitDialog = false + viewModel.clearRateLimitResult() + }) { + SelectionContainer { + Text("閉じる", color = Kinpaku) + } + } + }, + containerColor = Sumi, + titleContentColor = Kinpaku, + textContentColor = Zouge + ) + } + } +} + +@Composable +fun PaneCard( + pane: PaneInfo, + onClick: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .height(160.dp) + .clickable(onClick = onClick), + colors = CardDefaults.cardColors(containerColor = Color(0x802D2D2D)) + ) { + SelectionContainer { + Column(modifier = Modifier.padding(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = pane.agentId.ifBlank { "pane${pane.index}" }, + color = Kinpaku, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace + ) + if (pane.modelName.isNotBlank()) { + Text( + text = " (${pane.modelName})", + color = Color(0xFFAAAAAA), + fontSize = 10.sp, + fontFamily = FontFamily.Monospace + ) + } + } + Spacer(modifier = Modifier.height(4.dp)) + val tailLines = remember(pane.content) { + pane.content.lines().dropLastWhile { it.isBlank() }.takeLast(10).joinToString("\n") + } + Text( + text = parseAnsiColors(tailLines), + color = Zouge, + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + maxLines = 10, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} + +@Composable +fun PaneFullScreen( + pane: PaneInfo, + onBack: () -> Unit, + onSendCommand: (String) -> Unit, + onRefresh: () -> Unit +) { + val context = LocalContext.current + var commandTextValue by remember { mutableStateOf(TextFieldValue("")) } + var isListening by remember { mutableStateOf(false) } + val speechRecognizer = remember { + if (SpeechRecognizer.isRecognitionAvailable(context)) + SpeechRecognizer.createSpeechRecognizer(context) + else null + } + val horizontalScrollState = rememberScrollState() + val verticalScrollState = rememberScrollState() + val parsedPaneContent = remember(pane.content) { parseAnsiColors(pane.content) } + + DisposableEffect(Unit) { + onDispose { speechRecognizer?.destroy() } + } + + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + if (granted && speechRecognizer != null) { + startContinuousListening(speechRecognizer, { isListening }) { result -> + val newText = if (commandTextValue.text.isEmpty()) result else "${commandTextValue.text} $result" + commandTextValue = TextFieldValue(text = newText, selection = TextRange(newText.length)) + } + isListening = true + } + } + + // Keep following the newest output while preserving text-selection support. + LaunchedEffect(pane.content, verticalScrollState.maxValue) { + verticalScrollState.scrollTo(verticalScrollState.maxValue) + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Shikkoku) + ) { + Image( + painter = painterResource(R.drawable.bg_agents), + contentDescription = null, + contentScale = ContentScale.Crop, + alpha = 0.55f, + modifier = Modifier.fillMaxSize() + ) + Column( + modifier = Modifier.fillMaxSize() + ) { + // Top bar with agent name and back button + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color(0x802D2D2D)) + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "戻る", + tint = Kinpaku + ) + } + SelectionContainer { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = pane.agentId.ifBlank { "pane${pane.index}" }, + color = Kinpaku, + fontSize = 16.sp, + fontFamily = FontFamily.Monospace + ) + if (pane.modelName.isNotBlank()) { + Text( + text = " (${pane.modelName})", + color = Color(0xFFAAAAAA), + fontSize = 12.sp, + fontFamily = FontFamily.Monospace + ) + } + } + } + } + + // Full screen pane content + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .horizontalScroll(horizontalScrollState) + ) { + SelectionContainer { + Text( + text = parsedPaneContent, + color = Zouge, + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + softWrap = false, + modifier = Modifier + .fillMaxHeight() + .verticalScroll(verticalScrollState) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + } // Box (horizontal scroll) + + // Special keys bar + SpecialKeysRow(onSendKey = { onSendCommand(it) }) + + // Command input at bottom + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = commandTextValue, + onValueChange = { commandTextValue = it }, + modifier = Modifier.weight(1f), + placeholder = { Text("コマンドを入力") }, + singleLine = true + ) + Spacer(modifier = Modifier.width(4.dp)) + // Voice input button + IconButton( + onClick = { + if (speechRecognizer == null) return@IconButton + if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) + == PackageManager.PERMISSION_GRANTED + ) { + if (isListening) { + speechRecognizer.cancel() + isListening = false + } else { + startContinuousListening(speechRecognizer, { isListening }) { result -> + val newText = if (commandTextValue.text.isEmpty()) result else "${commandTextValue.text} $result" + commandTextValue = TextFieldValue(text = newText, selection = TextRange(newText.length)) + } + isListening = true + } + } else { + permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + } + ) { + Icon( + imageVector = Icons.Default.Mic, + contentDescription = "音声入力", + tint = if (isListening) Kurenai else Kinpaku + ) + } + Spacer(modifier = Modifier.width(4.dp)) + IconButton( + onClick = { + if (commandTextValue.text.isNotBlank()) { + onSendCommand(commandTextValue.text) + commandTextValue = TextFieldValue("") + } + }, + enabled = commandTextValue.text.isNotBlank() && !isListening + ) { + Icon( + imageVector = Icons.Default.Send, + contentDescription = "送信", + tint = if (commandTextValue.text.isNotBlank() && !isListening) Kinpaku else TextMuted + ) + } + } + } // Column + } // Box +} + +// ── Rate Limit UI ───────────────────────────────────────────────────────────── +@Composable +private fun RateLimitContent(rawText: String) { + val data = remember(rawText) { parseRateLimitResult(rawText) } + val claudeMax = data.claudeMax + val codexQuota = data.codexQuota + val codexEntries = data.codexEntries + val hasAnyData = claudeMax.window5h != null || claudeMax.window7d != null || + codexQuota.account5h != null || codexQuota.model5h != null || codexEntries.isNotEmpty() + SelectionContainer { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + if (!hasAnyData && rawText.isNotBlank()) { + Text("パース失敗 — Raw出力:", color = Color(0xFFCC4444), fontSize = 11.sp) + Text(rawText, color = Zouge, fontSize = 10.sp, fontFamily = FontFamily.Monospace) + } + + // ── Claude Max section ── + Text("Claude Max", color = Kinpaku, fontSize = 13.sp, fontFamily = FontFamily.Monospace) + Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(Color(0xFF555555))) + + claudeMax.window5h?.let { w -> + val color = rateLimitBarColor(w.percent) + Text("5時間枠", color = Zouge, fontSize = 12.sp) + LinearProgressIndicator( + progress = { w.percent / 100f }, + modifier = Modifier.fillMaxWidth(), + color = color, + trackColor = Color(0xFF444444) + ) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("${w.percent}%", color = color, fontSize = 11.sp) + Text(formatResetTime(w.resetStr), color = Color(0xFF888888), fontSize = 11.sp) + } + } + + claudeMax.window7d?.let { w -> + val color = rateLimitBarColor(w.percent) + Text("7日枠", color = Zouge, fontSize = 12.sp) + LinearProgressIndicator( + progress = { w.percent / 100f }, + modifier = Modifier.fillMaxWidth(), + color = color, + trackColor = Color(0xFF444444) + ) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("${w.percent}%", color = color, fontSize = 11.sp) + Text(formatResetTime(w.resetStr), color = Color(0xFF888888), fontSize = 11.sp) + } + if (claudeMax.sonnet7d != null || claudeMax.opus7d != null) { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + claudeMax.sonnet7d?.let { Text("Sonnet: ${it}%", color = Color(0xFF888888), fontSize = 11.sp) } + claudeMax.opus7d?.let { Text("Opus: ${it}%", color = Color(0xFF888888), fontSize = 11.sp) } + } + } + } + + claudeMax.todayTokens?.let { tokens -> + Text("本日トークン", color = Zouge, fontSize = 12.sp) + Text(tokens, color = Kinpaku, fontSize = 15.sp, fontFamily = FontFamily.Monospace) + } + + if (claudeMax.sessions != null || claudeMax.messages != null) { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + claudeMax.sessions?.let { Text("セッション: $it", color = Color(0xFF888888), fontSize = 11.sp) } + claudeMax.messages?.let { Text("メッセージ: $it", color = Color(0xFF888888), fontSize = 11.sp) } + } + } + + // ── Codex Quota section ── + if (codexQuota.account5h != null || codexQuota.model5h != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text("ChatGPT Pro クォータ", color = Kinpaku, fontSize = 13.sp, fontFamily = FontFamily.Monospace) + Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(Color(0xFF555555))) + + // Account-level quota + codexQuota.account5h?.let { w -> + val color = rateLimitBarColor(w.percent) + Text("Account 5時間枠", color = Zouge, fontSize = 12.sp) + LinearProgressIndicator( + progress = { w.percent / 100f }, + modifier = Modifier.fillMaxWidth(), + color = color, + trackColor = Color(0xFF444444) + ) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("${w.percent.toInt()}% used", color = color, fontSize = 11.sp) + Text("resets ${w.resetStr}", color = Color(0xFF888888), fontSize = 11.sp) + } + } + codexQuota.account7d?.let { w -> + val color = rateLimitBarColor(w.percent) + Text("Account Weekly", color = Zouge, fontSize = 12.sp) + LinearProgressIndicator( + progress = { w.percent / 100f }, + modifier = Modifier.fillMaxWidth(), + color = color, + trackColor = Color(0xFF444444) + ) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("${w.percent.toInt()}% used", color = color, fontSize = 11.sp) + Text("resets ${w.resetStr}", color = Color(0xFF888888), fontSize = 11.sp) + } + } + + // Model-level quota + if (codexQuota.model5h != null) { + val label = codexQuota.modelName ?: "Model" + codexQuota.model5h.let { w -> + val color = rateLimitBarColor(w.percent) + Text("$label 5時間枠", color = Zouge, fontSize = 12.sp) + LinearProgressIndicator( + progress = { w.percent / 100f }, + modifier = Modifier.fillMaxWidth(), + color = color, + trackColor = Color(0xFF444444) + ) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("${w.percent.toInt()}% used", color = color, fontSize = 11.sp) + Text("resets ${w.resetStr}", color = Color(0xFF888888), fontSize = 11.sp) + } + } + codexQuota.model7d?.let { w -> + val color = rateLimitBarColor(w.percent) + Text("$label Weekly", color = Zouge, fontSize = 12.sp) + LinearProgressIndicator( + progress = { w.percent / 100f }, + modifier = Modifier.fillMaxWidth(), + color = color, + trackColor = Color(0xFF444444) + ) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("${w.percent.toInt()}% used", color = color, fontSize = 11.sp) + Text("resets ${w.resetStr}", color = Color(0xFF888888), fontSize = 11.sp) + } + } + } + } + + // ── Codex context per agent ── + if (codexEntries.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Text("Codex5.3 コンテキスト", color = Kinpaku, fontSize = 13.sp, fontFamily = FontFamily.Monospace) + Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(Color(0xFF555555))) + + codexEntries.forEach { entry -> + val label = "ash${entry.ashigaru}" + val pct = entry.percent + if (pct != null) { + val usedPct = 100f - pct + val color = rateLimitBarColor(usedPct) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(label, color = Zouge, fontSize = 11.sp, modifier = Modifier.width(40.dp)) + LinearProgressIndicator( + progress = { usedPct / 100f }, + modifier = Modifier.weight(1f), + color = color, + trackColor = Color(0xFF444444) + ) + Text("${pct.toInt()}%", color = color, fontSize = 11.sp) + } + } else { + Row(modifier = Modifier.fillMaxWidth()) { + Text("$label: ?", color = Color(0xFF888888), fontSize = 11.sp) + } + } + } + } + } + } +} diff --git a/android/app/src/main/java/com/shogun/android/ui/DashboardScreen.kt b/android/app/src/main/java/com/shogun/android/ui/DashboardScreen.kt index e545b66d7..153947b02 100644 --- a/android/app/src/main/java/com/shogun/android/ui/DashboardScreen.kt +++ b/android/app/src/main/java/com/shogun/android/ui/DashboardScreen.kt @@ -1,124 +1,131 @@ -package com.shogun.android.ui - -import android.webkit.WebView -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.ui.graphics.Color -import com.shogun.android.ui.theme.* -import com.shogun.android.util.Defaults -import com.shogun.android.util.PrefsKeys -import androidx.compose.ui.layout.ContentScale -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.lifecycle.viewmodel.compose.viewModel -import com.shogun.android.R -import com.shogun.android.viewmodel.DashboardViewModel -import org.commonmark.ext.gfm.tables.TablesExtension -import org.commonmark.parser.Parser -import org.commonmark.renderer.html.HtmlRenderer - -@Composable -fun DashboardScreen( - viewModel: DashboardViewModel = viewModel() -) { - val context = LocalContext.current - val markdownContent by viewModel.markdownContent.collectAsState() - val isLoading by viewModel.isLoading.collectAsState() - val errorMessage by viewModel.errorMessage.collectAsState() - - val htmlContent = remember(markdownContent) { - if (markdownContent.isBlank()) "" else markdownToHtml(markdownContent) - } - - LaunchedEffect(Unit) { - val prefs = context.getSharedPreferences(PrefsKeys.PREFS_NAME, android.content.Context.MODE_PRIVATE) - val host = prefs.getString(PrefsKeys.SSH_HOST, Defaults.SSH_HOST) ?: Defaults.SSH_HOST - val port = prefs.getString(PrefsKeys.SSH_PORT, Defaults.SSH_PORT_STR)?.toIntOrNull() ?: Defaults.SSH_PORT - val user = prefs.getString(PrefsKeys.SSH_USER, "") ?: "" - val keyPath = prefs.getString(PrefsKeys.SSH_KEY_PATH, "") ?: "" - val password = prefs.getString(PrefsKeys.SSH_PASSWORD, "") ?: "" - viewModel.connect(host, port, user, keyPath, password) - } - - Box( - modifier = Modifier - .fillMaxSize() - .background(Shikkoku) - ) { - Image( - painter = painterResource(R.drawable.bg_castle), - contentDescription = null, - contentScale = ContentScale.Crop, - alpha = 0.55f, - modifier = Modifier.fillMaxSize() - ) - if (errorMessage != null) { - Box( - modifier = Modifier.fillMaxSize().padding(16.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = "エラー: $errorMessage", - color = MaterialTheme.colorScheme.error - ) - } - } else if (markdownContent.isBlank() && !isLoading) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text("読み込み中…", color = Zouge) - } - } else { - AndroidView( - factory = { ctx -> - WebView(ctx).apply { - setBackgroundColor(android.graphics.Color.TRANSPARENT) - settings.javaScriptEnabled = false - settings.allowContentAccess = true - settings.allowFileAccess = true - settings.domStorageEnabled = true - settings.setSupportMultipleWindows(true) - isFocusable = true - isFocusableInTouchMode = true - isLongClickable = true - setOnLongClickListener { false } - setOnTouchListener { _, _ -> false } - } - }, - update = { webView -> - if (htmlContent.isNotBlank()) { - val fullHtml = buildDashboardHtml(htmlContent) - webView.loadDataWithBaseURL(null, fullHtml, "text/html", "UTF-8", null) - } - }, - modifier = Modifier.fillMaxSize() - ) - } - } // Box -} - -private fun markdownToHtml(markdown: String): String { - val extensions = listOf(TablesExtension.create()) - val parser = Parser.builder().extensions(extensions).build() - val renderer = HtmlRenderer.builder().extensions(extensions).build() - val document = parser.parse(markdown) - return renderer.render(document) -} - -private fun buildDashboardHtml(bodyHtml: String): String = """ - - - - - - -$bodyHtml -""".trimIndent() +h1, h2, h3, h4 { color: #C9A94E; margin-top: 16px; margin-bottom: 8px; } +h1 { font-size: 20px; } +h2 { font-size: 17px; } +h3 { font-size: 15px; } +table { border-collapse: collapse; width: 100%; margin: 8px 0; } +th, td { border: 1px solid #555; padding: 6px 8px; text-align: left; } +th { background-color: rgba(60,60,60,0.8); color: #C9A94E; } +tr:nth-child(even) { background-color: rgba(45,45,45,0.5); } +a { color: #D4B96A; } +code { background-color: #333; padding: 1px 4px; border-radius: 3px; font-size: 13px; } +pre { background-color: #222; padding: 8px; border-radius: 4px; overflow-x: auto; } +pre code { background: none; padding: 0; } +ul, ol { padding-left: 20px; } +li { margin-bottom: 4px; } +hr { border: none; border-top: 1px solid #555; margin: 12px 0; } +::selection { background: #C9A94E; color: #1A1A1A; } + + +$bodyHtml +""".trimIndent() diff --git a/android/app/src/main/java/com/shogun/android/ui/SettingsScreen.kt b/android/app/src/main/java/com/shogun/android/ui/SettingsScreen.kt index a0bfbe837..54d1b9005 100644 --- a/android/app/src/main/java/com/shogun/android/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/shogun/android/ui/SettingsScreen.kt @@ -13,52 +13,317 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.* +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import com.shogun.android.ui.theme.* -import com.shogun.android.util.Defaults -import com.shogun.android.util.PrefsKeys import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel +import com.shogun.android.data.Profile +import com.shogun.android.ui.theme.* import com.shogun.android.util.AppLogger +import com.shogun.android.util.Defaults +import com.shogun.android.viewmodel.ProfileViewModel import com.shogun.android.viewmodel.SettingsViewModel import java.io.File @Composable -fun SettingsScreen(settingsViewModel: SettingsViewModel = viewModel()) { +fun SettingsScreen( + profileViewModel: ProfileViewModel, + settingsViewModel: SettingsViewModel = viewModel() +) { val context = LocalContext.current - val prefs = context.getSharedPreferences(PrefsKeys.PREFS_NAME, Context.MODE_PRIVATE) - - var host by remember { mutableStateOf(prefs.getString(PrefsKeys.SSH_HOST, Defaults.SSH_HOST) ?: Defaults.SSH_HOST) } - var port by remember { mutableStateOf(prefs.getString(PrefsKeys.SSH_PORT, Defaults.SSH_PORT_STR) ?: Defaults.SSH_PORT_STR) } - var user by remember { mutableStateOf(prefs.getString(PrefsKeys.SSH_USER, "") ?: "") } - var keyPath by remember { mutableStateOf(prefs.getString(PrefsKeys.SSH_KEY_PATH, "") ?: "") } - var password by remember { mutableStateOf(prefs.getString(PrefsKeys.SSH_PASSWORD, "") ?: "") } - var projectPath by remember { mutableStateOf(prefs.getString(PrefsKeys.PROJECT_PATH, "") ?: "") } - var shogunSession by remember { mutableStateOf(prefs.getString(PrefsKeys.SHOGUN_SESSION, Defaults.SHOGUN_SESSION) ?: Defaults.SHOGUN_SESSION) } - var agentsSession by remember { mutableStateOf(prefs.getString(PrefsKeys.AGENTS_SESSION, Defaults.AGENTS_SESSION) ?: Defaults.AGENTS_SESSION) } - - var saved by remember { mutableStateOf(false) } + val profiles by profileViewModel.profiles.collectAsState() + val activeProfile by profileViewModel.activeProfile.collectAsState() + + var editingProfileId by remember { mutableStateOf(activeProfile?.id) } + + // Keep editingProfileId valid: if the profile was deleted, fall back to active + LaunchedEffect(profiles) { + if (editingProfileId != null && profiles.none { it.id == editingProfileId }) { + editingProfileId = activeProfile?.id + } + } + + val editingProfile = profiles.find { it.id == editingProfileId } ?: activeProfile + + // Dialog states + var showAddDialog by remember { mutableStateOf(false) } + var addNameInput by remember { mutableStateOf("") } + var duplicateSourceId by remember { mutableStateOf(null) } + var duplicateNameInput by remember { mutableStateOf("") } var tapCount by remember { mutableIntStateOf(0) } var showDebugLog by remember { mutableStateOf(false) } + + // Add profile dialog + if (showAddDialog) { + AlertDialog( + onDismissRequest = { showAddDialog = false; addNameInput = "" }, + containerColor = Shikkoku, + title = { Text("新規プロファイル", color = Kinpaku) }, + text = { + OutlinedTextField( + value = addNameInput, + onValueChange = { addNameInput = it }, + label = { Text("プロファイル名") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + TextButton( + onClick = { + val trimmed = addNameInput.trim() + if (trimmed.isNotBlank()) { + val newProfile = Profile(name = trimmed) + profileViewModel.addProfile(newProfile) + editingProfileId = newProfile.id + addNameInput = "" + showAddDialog = false + } + }, + enabled = addNameInput.isNotBlank() + ) { Text("作成", color = Kinpaku) } + }, + dismissButton = { + TextButton(onClick = { showAddDialog = false; addNameInput = "" }) { + Text("キャンセル", color = TextMuted) + } + } + ) + } + + // Duplicate profile dialog + duplicateSourceId?.let { sourceId -> + AlertDialog( + onDismissRequest = { duplicateSourceId = null; duplicateNameInput = "" }, + containerColor = Shikkoku, + title = { Text("プロファイルを複製", color = Kinpaku) }, + text = { + OutlinedTextField( + value = duplicateNameInput, + onValueChange = { duplicateNameInput = it }, + label = { Text("新しいプロファイル名") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + TextButton( + onClick = { + val trimmed = duplicateNameInput.trim() + if (trimmed.isNotBlank()) { + val result = profileViewModel.duplicateProfile(sourceId, trimmed) + if (result != null) { + editingProfileId = result.id + } else { + Toast.makeText(context, "複製に失敗しました", Toast.LENGTH_SHORT).show() + } + duplicateSourceId = null + duplicateNameInput = "" + } + }, + enabled = duplicateNameInput.isNotBlank() + ) { Text("複製", color = Kinpaku) } + }, + dismissButton = { + TextButton(onClick = { duplicateSourceId = null; duplicateNameInput = "" }) { + Text("キャンセル", color = TextMuted) + } + } + ) + } + + if (showDebugLog) { + DebugLogDialog(onDismiss = { showDebugLog = false }) + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(Shikkoku) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + "プロファイル管理", + style = MaterialTheme.typography.titleLarge, + color = Kinpaku, + modifier = Modifier.clickable { + tapCount++ + if (tapCount >= 7) { + showDebugLog = true + tapCount = 0 + } + } + ) + + ProfileListSection( + profiles = profiles, + activeProfileId = activeProfile?.id, + editingProfileId = editingProfileId, + onProfileClick = { id -> + editingProfileId = id + }, + onAddClick = { showAddDialog = true }, + onDuplicateClick = { id -> + val source = profiles.find { it.id == id } + duplicateNameInput = "${source?.name ?: "プロファイル"}_コピー" + duplicateSourceId = id + }, + onDeleteClick = { id -> + if (id == activeProfile?.id) { + Toast.makeText(context, "現在選択中のプロファイルは削除できません", Toast.LENGTH_SHORT).show() + } else { + profileViewModel.deleteProfile(id) + } + } + ) + + if (editingProfile != null) { + Divider(color = Sumi) + Text("プロファイル設定", style = MaterialTheme.typography.titleMedium, color = Kinpaku) + ProfileEditSection( + profile = editingProfile, + onSave = { updated -> profileViewModel.updateProfile(updated) } + ) + } else { + Text( + "プロファイルを追加してください", + color = TextMuted, + style = MaterialTheme.typography.bodyMedium + ) + } + + Divider(color = Sumi) + NtfySettingsSection(viewModel = settingsViewModel) + } +} + +@Composable +private fun ProfileListSection( + profiles: List, + activeProfileId: String?, + editingProfileId: String?, + onProfileClick: (String) -> Unit, + onAddClick: () -> Unit, + onDuplicateClick: (String) -> Unit, + onDeleteClick: (String) -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { + profiles.forEach { profile -> + ProfileListItem( + profile = profile, + isActive = profile.id == activeProfileId, + isEditing = profile.id == editingProfileId, + onClick = { onProfileClick(profile.id) }, + onDuplicate = { onDuplicateClick(profile.id) }, + onDelete = { onDeleteClick(profile.id) } + ) + } + OutlinedButton( + onClick = onAddClick, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(4.dp) + ) { + Text("+ プロファイルを追加", color = Kinpaku) + } + } +} + +@Composable +private fun ProfileListItem( + profile: Profile, + isActive: Boolean, + isEditing: Boolean, + onClick: () -> Unit, + onDuplicate: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier +) { + Surface( + color = if (isEditing) Sumi else Color.Transparent, + shape = RoundedCornerShape(4.dp), + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + RadioButton( + selected = isEditing, + onClick = onClick, + colors = RadioButtonDefaults.colors( + selectedColor = Kinpaku, + unselectedColor = TextMuted + ) + ) + Text( + text = profile.name, + color = if (isActive) Kinpaku else Zouge, + style = MaterialTheme.typography.bodyMedium + ) + } + Row { + IconButton(onClick = onDuplicate) { + Icon(Icons.Default.ContentCopy, contentDescription = "複製", tint = TextMuted) + } + IconButton(onClick = onDelete) { + Icon(Icons.Default.Delete, contentDescription = "削除", tint = Shuaka) + } + } + } + } +} + +@Composable +private fun ProfileEditSection( + profile: Profile, + onSave: (Profile) -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + + // Form state keyed to profile.id — resets when switching to a different profile + var name by remember(profile.id) { mutableStateOf(profile.name) } + var host by remember(profile.id) { mutableStateOf(profile.sshHost) } + var port by remember(profile.id) { mutableStateOf(profile.sshPort.toString()) } + var user by remember(profile.id) { mutableStateOf(profile.sshUser) } + var keyPath by remember(profile.id) { mutableStateOf(profile.sshKeyPath) } + var password by remember(profile.id) { mutableStateOf(profile.sshPassword) } + var projectPath by remember(profile.id) { mutableStateOf(profile.projectPath) } + var shogunSession by remember(profile.id) { mutableStateOf(profile.shogunSession) } + var agentsSession by remember(profile.id) { mutableStateOf(profile.agentsSession) } + var dashboardFileName by remember(profile.id) { mutableStateOf(profile.dashboardFileName) } + var saved by remember(profile.id) { mutableStateOf(false) } + val pickSshKeyLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenDocument() ) { uri -> if (uri == null) return@rememberLauncherForActivityResult - runCatching { copySshKeyToAppStorage(context, uri) } .onSuccess { importedPath -> keyPath = importedPath @@ -69,58 +334,43 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel = viewModel()) { Toast.makeText(context, "秘密鍵取込失敗: ${error.message}", Toast.LENGTH_LONG).show() } } - - // Debug log dialog - if (showDebugLog) { - DebugLogDialog(onDismiss = { showDebugLog = false }) - } - - Column( - modifier = Modifier - .fillMaxSize() - .background(Shikkoku) - .padding(16.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - "SSH設定", - style = MaterialTheme.typography.titleLarge, - color = Kinpaku, - modifier = Modifier.clickable { - tapCount++ - if (tapCount >= 7) { - showDebugLog = true - tapCount = 0 - } - } - ) - - OutlinedTextField( - value = host, - onValueChange = { host = it }, - label = { Text("SSHホスト") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) - - OutlinedTextField( - value = port, - onValueChange = { port = it }, - label = { Text("SSHポート") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) - ) - - OutlinedTextField( - value = user, - onValueChange = { user = it }, - label = { Text("SSHユーザー") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) - + + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField( + value = name, + onValueChange = { name = it; saved = false }, + label = { Text("プロファイル名") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + Text("SSH設定", style = MaterialTheme.typography.titleSmall, color = Kinpaku) + + OutlinedTextField( + value = host, + onValueChange = { host = it; saved = false }, + label = { Text("SSHホスト") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + OutlinedTextField( + value = port, + onValueChange = { port = it; saved = false }, + label = { Text("SSHポート") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + ) + + OutlinedTextField( + value = user, + onValueChange = { user = it; saved = false }, + label = { Text("SSHユーザー") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -128,15 +378,11 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel = viewModel()) { ) { OutlinedTextField( value = keyPath, - onValueChange = { - keyPath = it - saved = false - }, + onValueChange = { keyPath = it; saved = false }, label = { Text("SSH秘密鍵パス") }, modifier = Modifier.weight(1f), singleLine = true ) - OutlinedButton( onClick = { pickSshKeyLauncher.launch(arrayOf("*/*")) }, modifier = Modifier.defaultMinSize(minHeight = 56.dp), @@ -145,86 +391,93 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel = viewModel()) { Text("ファイルを選択") } } - - OutlinedTextField( - value = password, - onValueChange = { password = it }, - label = { Text("SSHパスワード(鍵なし時に使用)") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - visualTransformation = PasswordVisualTransformation() - ) - - Divider() - - Text("プロジェクト設定", style = MaterialTheme.typography.titleMedium, color = Kinpaku) - - OutlinedTextField( - value = projectPath, - onValueChange = { projectPath = it }, - label = { Text("プロジェクトパス(サーバー側)") }, - placeholder = { Text("/path/to/multi-agent-shogun") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) - - Divider() - - Text("セッション設定", style = MaterialTheme.typography.titleMedium, color = Kinpaku) - - OutlinedTextField( - value = shogunSession, - onValueChange = { shogunSession = it }, - label = { Text("将軍セッション名") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) - - OutlinedTextField( - value = agentsSession, - onValueChange = { agentsSession = it }, - label = { Text("エージェントセッション名") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) - - Divider() - - NtfySettingsSection(viewModel = settingsViewModel) - - Divider() - - Button( - onClick = { - prefs.edit() - .putString(PrefsKeys.SSH_HOST, host) - .putString(PrefsKeys.SSH_PORT, port) - .putString(PrefsKeys.SSH_USER, user) - .putString(PrefsKeys.SSH_KEY_PATH, keyPath) - .putString(PrefsKeys.SSH_PASSWORD, password) - .putString(PrefsKeys.PROJECT_PATH, projectPath) - .putString(PrefsKeys.SHOGUN_SESSION, shogunSession) - .putString(PrefsKeys.AGENTS_SESSION, agentsSession) - .apply() - saved = true - }, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors( - containerColor = Shuaka, - contentColor = Color.White - ), - shape = RoundedCornerShape(4.dp) - ) { - Text("保存") - } - - if (saved) { - Text( - text = "設定を保存しました", - color = MaterialTheme.colorScheme.primary - ) - } - } + + OutlinedTextField( + value = password, + onValueChange = { password = it; saved = false }, + label = { Text("SSHパスワード(鍵なし時に使用)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = PasswordVisualTransformation() + ) + + Divider(color = Sumi) + Text("プロジェクト設定", style = MaterialTheme.typography.titleSmall, color = Kinpaku) + + OutlinedTextField( + value = projectPath, + onValueChange = { projectPath = it; saved = false }, + label = { Text("プロジェクトパス(サーバー側)") }, + placeholder = { Text("/path/to/multi-agent-shogun") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + Divider(color = Sumi) + Text("セッション設定", style = MaterialTheme.typography.titleSmall, color = Kinpaku) + + OutlinedTextField( + value = shogunSession, + onValueChange = { shogunSession = it; saved = false }, + label = { Text("将軍セッション名") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + OutlinedTextField( + value = agentsSession, + onValueChange = { agentsSession = it; saved = false }, + label = { Text("エージェントセッション名") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + OutlinedTextField( + value = dashboardFileName, + onValueChange = { dashboardFileName = it; saved = false }, + label = { Text("ダッシュボードファイル名") }, + placeholder = { Text("dashboard.md") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + Button( + onClick = { + if (name.isNotBlank()) { + val updated = profile.copy( + name = name.trim(), + sshHost = host.trim(), + sshPort = port.toIntOrNull() ?: Defaults.SSH_PORT, + sshUser = user.trim(), + sshKeyPath = keyPath.trim(), + sshPassword = password, + projectPath = projectPath.trim(), + shogunSession = shogunSession.trim(), + agentsSession = agentsSession.trim(), + dashboardFileName = dashboardFileName.trim().ifBlank { "dashboard.md" } + ) + onSave(updated) + saved = true + } + }, + modifier = Modifier.fillMaxWidth(), + enabled = name.isNotBlank(), + colors = ButtonDefaults.buttonColors( + containerColor = Shuaka, + contentColor = Color.White + ), + shape = RoundedCornerShape(4.dp) + ) { + Text("保存") + } + + if (saved) { + Text( + text = "設定を保存しました", + color = MaterialTheme.colorScheme.primary + ) + } + } } private fun copySshKeyToAppStorage(context: Context, uri: Uri): String { @@ -257,62 +510,61 @@ private fun copySshKeyToAppStorage(context: Context, uri: Uri): String { @Composable fun DebugLogDialog(onDismiss: () -> Unit) { - val context = LocalContext.current - val entries = remember { AppLogger.getEntries() } - val listState = rememberLazyListState() - - LaunchedEffect(entries.size) { - if (entries.isNotEmpty()) listState.scrollToItem(entries.size - 1) - } - - AlertDialog( - onDismissRequest = onDismiss, - containerColor = Shikkoku, - title = { - Text("Debug Log (${entries.size})", color = Kinpaku) - }, - text = { - Column { - // Copy to clipboard button - TextButton(onClick = { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("debug_log", entries.joinToString("\n")) - clipboard.setPrimaryClip(clip) - Toast.makeText(context, "ログをコピーしました", Toast.LENGTH_SHORT).show() - }) { - Text("Copy All", color = Kinpaku) - } - LazyColumn( - state = listState, - modifier = Modifier - .fillMaxWidth() - .height(380.dp) - ) { - items(entries) { entry -> - Text( - text = entry, - color = if (entry.contains("FAIL") || entry.contains("ERROR")) - Color(0xFFCC3333) else Color(0xFFAABBCC), - fontFamily = FontFamily.Monospace, - fontSize = 10.sp, - modifier = Modifier.padding(vertical = 1.dp) - ) - } - } - } - }, - confirmButton = { - TextButton(onClick = { - AppLogger.clear() - onDismiss() - }) { - Text("Clear & Close", color = Kinpaku) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Close", color = Color(0xFF888888)) - } - } - ) -} + val context = LocalContext.current + val entries = remember { AppLogger.getEntries() } + val listState = rememberLazyListState() + + LaunchedEffect(entries.size) { + if (entries.isNotEmpty()) listState.scrollToItem(entries.size - 1) + } + + AlertDialog( + onDismissRequest = onDismiss, + containerColor = Shikkoku, + title = { + Text("Debug Log (${entries.size})", color = Kinpaku) + }, + text = { + Column { + TextButton(onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("debug_log", entries.joinToString("\n")) + clipboard.setPrimaryClip(clip) + Toast.makeText(context, "ログをコピーしました", Toast.LENGTH_SHORT).show() + }) { + Text("Copy All", color = Kinpaku) + } + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxWidth() + .height(380.dp) + ) { + items(entries) { entry -> + Text( + text = entry, + color = if (entry.contains("FAIL") || entry.contains("ERROR")) + Color(0xFFCC3333) else Color(0xFFAABBCC), + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + modifier = Modifier.padding(vertical = 1.dp) + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = { + AppLogger.clear() + onDismiss() + }) { + Text("Clear & Close", color = Kinpaku) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Close", color = Color(0xFF888888)) + } + } + ) +} diff --git a/android/app/src/main/java/com/shogun/android/ui/ShogunScreen.kt b/android/app/src/main/java/com/shogun/android/ui/ShogunScreen.kt index 6dc7aacfe..62ed109a5 100644 --- a/android/app/src/main/java/com/shogun/android/ui/ShogunScreen.kt +++ b/android/app/src/main/java/com/shogun/android/ui/ShogunScreen.kt @@ -1,421 +1,423 @@ -package com.shogun.android.ui - -import android.Manifest -import android.content.Intent -import android.content.pm.PackageManager -import android.media.MediaPlayer -import android.os.Bundle -import android.speech.RecognitionListener -import android.speech.RecognizerIntent -import android.speech.SpeechRecognizer -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.KeyboardArrowDown -import androidx.compose.material.icons.filled.KeyboardArrowUp -import androidx.compose.material.icons.filled.Mic -import androidx.compose.material.icons.filled.Send -import androidx.compose.material.icons.filled.VolumeOff -import androidx.compose.material.icons.filled.VolumeUp -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import com.shogun.android.ui.theme.* -import com.shogun.android.util.Defaults -import com.shogun.android.util.PrefsKeys -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.TextRange -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.core.content.ContextCompat -import androidx.lifecycle.viewmodel.compose.viewModel -import com.shogun.android.R -import com.shogun.android.viewmodel.ShogunViewModel - -@Composable -fun ShogunScreen( - viewModel: ShogunViewModel = viewModel(), - mediaPlayer: MediaPlayer? = null, - isBgmPlaying: Boolean = false, - bgmTrackLabel: String = "", - onBgmToggle: () -> Unit = {} -) { - val context = LocalContext.current - val paneContent by viewModel.paneContent.collectAsState() - val isConnected by viewModel.isConnected.collectAsState() - val errorMessage by viewModel.errorMessage.collectAsState() - - var inputTextValue by remember { mutableStateOf(TextFieldValue("")) } - var isListening by remember { mutableStateOf(false) } - var isInputExpanded by remember { mutableStateOf(false) } - - // Duck BGM while voice input is active - LaunchedEffect(isListening) { - if (isListening) { - mediaPlayer?.setVolume(0.05f, 0.05f) - } else { - mediaPlayer?.setVolume(1.0f, 1.0f) - } - } - - val listState = rememberLazyListState() - val lines = remember(paneContent) { paneContent.lines() } - - val speechRecognizer = remember { - if (SpeechRecognizer.isRecognitionAvailable(context)) - SpeechRecognizer.createSpeechRecognizer(context) - else null - } - - val permissionLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission() - ) { granted -> - if (granted && speechRecognizer != null) { - startContinuousListening(speechRecognizer, { isListening }) { result -> - val newText = if (inputTextValue.text.isEmpty()) result else "${inputTextValue.text} $result" - inputTextValue = TextFieldValue(text = newText, selection = TextRange(newText.length)) - } - isListening = true - } - } - - // Auto-connect on composition - LaunchedEffect(Unit) { - val prefs = context.getSharedPreferences(PrefsKeys.PREFS_NAME, android.content.Context.MODE_PRIVATE) - val host = prefs.getString(PrefsKeys.SSH_HOST, Defaults.SSH_HOST) ?: Defaults.SSH_HOST - val port = prefs.getString(PrefsKeys.SSH_PORT, Defaults.SSH_PORT_STR)?.toIntOrNull() ?: Defaults.SSH_PORT - val user = prefs.getString(PrefsKeys.SSH_USER, "") ?: "" - val keyPath = prefs.getString(PrefsKeys.SSH_KEY_PATH, "") ?: "" - val password = prefs.getString(PrefsKeys.SSH_PASSWORD, "") ?: "" - viewModel.connect(host, port, user, keyPath, password) - } - - // Pause refresh when app is in background - val lifecycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifecycleOwner) { - val observer = LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_RESUME -> { - viewModel.resumeRefresh() - if (isListening && speechRecognizer != null) { - startContinuousListening(speechRecognizer, { isListening }) { result -> - val newText = if (inputTextValue.text.isEmpty()) result else "${inputTextValue.text} $result" - inputTextValue = TextFieldValue(text = newText, selection = TextRange(newText.length)) - } - } - } - Lifecycle.Event.ON_PAUSE -> { - viewModel.pauseRefresh() - speechRecognizer?.cancel() - } - else -> {} - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } - } - - // Auto-scroll to bottom when content changes - LaunchedEffect(lines.size) { - if (lines.isNotEmpty()) { - listState.scrollToItem(lines.size - 1) - } - } - - Box( - modifier = Modifier - .fillMaxSize() - .background(Shikkoku) - ) { - Image( - painter = painterResource(R.drawable.bg_shogun), - contentDescription = null, - contentScale = ContentScale.Crop, - alpha = 0.55f, - modifier = Modifier.fillMaxSize() - ) - Column(modifier = Modifier.fillMaxSize()) { - // 陣幕バー — connection status - Row( - modifier = Modifier - .fillMaxWidth() - .background(if (isConnected) Matsuba else Kurenai) - .padding(4.dp), - horizontalArrangement = Arrangement.Center - ) { - Text( - text = if (isConnected) "接続中 — 将軍セッション" else "未接続", - color = Zouge, - fontSize = 12.sp - ) - } - - // Pane content display with LazyColumn - Box( - modifier = Modifier - .weight(1f) - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - ) { - if (errorMessage != null) { - Text( - text = "エラー: $errorMessage", - color = Kurenai, - fontFamily = FontFamily.Monospace, - fontSize = 13.sp, - modifier = Modifier.padding(8.dp) - ) - } else { - LazyColumn( - state = listState, - modifier = Modifier - .fillMaxHeight() - .padding(horizontal = 8.dp, vertical = 4.dp) - ) { - items(lines) { line -> - SelectionContainer { - Text( - text = parseAnsiColors(line), - color = Zouge, - fontFamily = FontFamily.Monospace, - fontSize = 13.sp, - softWrap = false - ) - } - } - } - } - } - - // Special keys bar - SpecialKeysRow(onSendKey = { viewModel.sendCommand(it) }) - - // Input area - Column( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedTextField( - value = inputTextValue, - onValueChange = { inputTextValue = it }, - modifier = Modifier.weight(1f), - placeholder = { Text("コマンドを入力", color = TextMuted) }, - singleLine = !isInputExpanded, - maxLines = if (isInputExpanded) 6 else 1, - colors = OutlinedTextFieldDefaults.colors( - focusedTextColor = Zouge, - unfocusedTextColor = Zouge, - focusedBorderColor = BorderFocus, - unfocusedBorderColor = BorderStandard, - cursorColor = Kinpaku, - focusedContainerColor = Surface4, - unfocusedContainerColor = Surface4, - ) - ) - - // Expand/collapse text button - IconButton( - onClick = { isInputExpanded = !isInputExpanded }, - modifier = Modifier.size(36.dp) - ) { - Icon( - imageVector = if (isInputExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, - contentDescription = "展開", - tint = Kinpaku - ) - } - } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically - ) { - // BGM toggle button — cycles through 3 tracks + OFF - IconButton(onClick = onBgmToggle) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Icon( - imageVector = if (isBgmPlaying) Icons.Default.VolumeUp else Icons.Default.VolumeOff, - contentDescription = "BGM", - tint = if (isBgmPlaying) Kinpaku else TextMuted - ) - if (isBgmPlaying && bgmTrackLabel.isNotEmpty()) { - Text( - text = bgmTrackLabel, - color = Kinpaku, - fontSize = 8.sp - ) - } - } - } - - // Voice input button (manual ON/OFF — stays on until user taps again) - IconButton( - onClick = { - if (speechRecognizer == null) return@IconButton - if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) - == PackageManager.PERMISSION_GRANTED - ) { - if (isListening) { - speechRecognizer.cancel() - isListening = false - } else { - startContinuousListening(speechRecognizer, { isListening }) { result -> - val newText = if (inputTextValue.text.isEmpty()) result else "${inputTextValue.text} $result" - inputTextValue = TextFieldValue(text = newText, selection = TextRange(newText.length)) - } - isListening = true - } - } else { - permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) - } - } - ) { - Icon( - imageVector = Icons.Default.Mic, - contentDescription = "音声入力", - tint = if (isListening) Kurenai else Kinpaku - ) - } - - // Send button - IconButton( - onClick = { - if (inputTextValue.text.isNotBlank()) { - viewModel.sendCommand(inputTextValue.text) - inputTextValue = TextFieldValue("") - } - }, - enabled = inputTextValue.text.isNotBlank() && isConnected && !isListening - ) { - Icon( - imageVector = Icons.Default.Send, - contentDescription = "送信", - tint = if (inputTextValue.text.isNotBlank() && isConnected && !isListening) Kinpaku else TextMuted - ) - } - } // Row (buttons) - } // Column (input area) - } // Column (main) - } // Box -} - -@Composable -fun SpecialKeysRow(onSendKey: (String) -> Unit) { - // Ordered by usage frequency for tmux + Claude Code workflow - val specialKeys = listOf( - "↵" to "\n", // Enter — most used (confirm commands, send input) - "C-c" to "\u0003", // Interrupt — stop running process - "C-b" to "\u0002", // tmux prefix — pane control (C-b C-b for background) - "↑" to "\u001b[A", // History up - "↓" to "\u001b[B", // History down - "Tab" to "\t", // Autocomplete - "ESC" to "\u001b", // Cancel / exit mode - "C-o" to "\u000f", // Accept line in Claude Code - "C-d" to "\u0004" // EOF / exit - ) - LazyRow( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - items(specialKeys) { (label, value) -> - OutlinedButton( - onClick = { onSendKey(value) }, - modifier = Modifier.height(32.dp), - contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp), - border = BorderStroke(1.dp, BorderFocus), - colors = ButtonDefaults.outlinedButtonColors( - containerColor = Surface4, - contentColor = Zouge - ) - ) { - Text( - text = label, - fontSize = 11.sp, - fontFamily = FontFamily.Monospace - ) - } - } - } -} - -/** - * Continuous listening — auto-restarts after each result. - * Checks isActive() before restarting to respect user's OFF toggle. - * Caller should use cancel() (not stopListening()) to stop cleanly. - */ -fun startContinuousListening( - speechRecognizer: SpeechRecognizer, - isActive: () -> Boolean, - onResult: (String) -> Unit -) { - val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { - putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) - putExtra(RecognizerIntent.EXTRA_LANGUAGE, "ja-JP") - putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1) - putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS, 5000L) - putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS, 5000L) - putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_MINIMUM_LENGTH_MILLIS, 2000L) - } - speechRecognizer.setRecognitionListener(object : RecognitionListener { - override fun onReadyForSpeech(params: Bundle?) {} - override fun onBeginningOfSpeech() {} - override fun onRmsChanged(rmsdB: Float) {} - override fun onBufferReceived(buffer: ByteArray?) {} - override fun onEndOfSpeech() {} - override fun onError(error: Int) { - if (!isActive()) return - when (error) { - SpeechRecognizer.ERROR_AUDIO, - SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> { - // Fatal — do not restart - } - else -> { - android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ - if (isActive()) { - try { speechRecognizer.startListening(intent) } catch (_: Exception) {} - } - }, 300) - } - } - } - override fun onResults(results: Bundle?) { - val matches = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) - if (!matches.isNullOrEmpty()) { - onResult(matches[0]) - } - if (isActive()) { - speechRecognizer.startListening(intent) - } - } - override fun onPartialResults(partialResults: Bundle?) {} - override fun onEvent(eventType: Int, params: Bundle?) {} - }) - speechRecognizer.startListening(intent) -} +package com.shogun.android.ui + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.media.MediaPlayer +import android.os.Bundle +import android.speech.RecognitionListener +import android.speech.RecognizerIntent +import android.speech.SpeechRecognizer +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.Send +import androidx.compose.material.icons.filled.VolumeOff +import androidx.compose.material.icons.filled.VolumeUp +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.shogun.android.ui.theme.* +import com.shogun.android.util.Defaults +import com.shogun.android.util.EncryptedPrefsProvider +import com.shogun.android.util.PrefsKeys +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat +import androidx.lifecycle.viewmodel.compose.viewModel +import com.shogun.android.R +import com.shogun.android.viewmodel.ShogunViewModel + +@Composable +fun ShogunScreen( + profileId: String? = null, + viewModel: ShogunViewModel = viewModel(), + mediaPlayer: MediaPlayer? = null, + isBgmPlaying: Boolean = false, + bgmTrackLabel: String = "", + onBgmToggle: () -> Unit = {} +) { + val context = LocalContext.current + val paneContent by viewModel.paneContent.collectAsState() + val isConnected by viewModel.isConnected.collectAsState() + val errorMessage by viewModel.errorMessage.collectAsState() + + var inputTextValue by remember { mutableStateOf(TextFieldValue("")) } + var isListening by remember { mutableStateOf(false) } + var isInputExpanded by remember { mutableStateOf(false) } + + // Duck BGM while voice input is active + LaunchedEffect(isListening) { + if (isListening) { + mediaPlayer?.setVolume(0.05f, 0.05f) + } else { + mediaPlayer?.setVolume(1.0f, 1.0f) + } + } + + val listState = rememberLazyListState() + val lines = remember(paneContent) { paneContent.lines() } + + val speechRecognizer = remember { + if (SpeechRecognizer.isRecognitionAvailable(context)) + SpeechRecognizer.createSpeechRecognizer(context) + else null + } + + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + if (granted && speechRecognizer != null) { + startContinuousListening(speechRecognizer, { isListening }) { result -> + val newText = if (inputTextValue.text.isEmpty()) result else "${inputTextValue.text} $result" + inputTextValue = TextFieldValue(text = newText, selection = TextRange(newText.length)) + } + isListening = true + } + } + + // Auto-connect on composition and profile switch + LaunchedEffect(profileId) { + val prefs = EncryptedPrefsProvider.getPreferences(context) + val host = prefs.getString(PrefsKeys.SSH_HOST, Defaults.SSH_HOST) ?: Defaults.SSH_HOST + val port = prefs.getString(PrefsKeys.SSH_PORT, Defaults.SSH_PORT_STR)?.toIntOrNull() ?: Defaults.SSH_PORT + val user = prefs.getString(PrefsKeys.SSH_USER, "") ?: "" + val keyPath = prefs.getString(PrefsKeys.SSH_KEY_PATH, "") ?: "" + val password = prefs.getString(PrefsKeys.SSH_PASSWORD, "") ?: "" + viewModel.connect(host, port, user, keyPath, password) + } + + // Pause refresh when app is in background + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> { + viewModel.resumeRefresh() + if (isListening && speechRecognizer != null) { + startContinuousListening(speechRecognizer, { isListening }) { result -> + val newText = if (inputTextValue.text.isEmpty()) result else "${inputTextValue.text} $result" + inputTextValue = TextFieldValue(text = newText, selection = TextRange(newText.length)) + } + } + } + Lifecycle.Event.ON_PAUSE -> { + viewModel.pauseRefresh() + speechRecognizer?.cancel() + } + else -> {} + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + // Auto-scroll to bottom when content changes + LaunchedEffect(lines.size) { + if (lines.isNotEmpty()) { + listState.scrollToItem(lines.size - 1) + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Shikkoku) + ) { + Image( + painter = painterResource(R.drawable.bg_shogun), + contentDescription = null, + contentScale = ContentScale.Crop, + alpha = 0.55f, + modifier = Modifier.fillMaxSize() + ) + Column(modifier = Modifier.fillMaxSize()) { + // 陣幕バー — connection status + Row( + modifier = Modifier + .fillMaxWidth() + .background(if (isConnected) Matsuba else Kurenai) + .padding(4.dp), + horizontalArrangement = Arrangement.Center + ) { + Text( + text = if (isConnected) "接続中 — 将軍セッション" else "未接続", + color = Zouge, + fontSize = 12.sp + ) + } + + // Pane content display with LazyColumn + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + ) { + if (errorMessage != null) { + Text( + text = "エラー: $errorMessage", + color = Kurenai, + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + modifier = Modifier.padding(8.dp) + ) + } else { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxHeight() + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + items(lines) { line -> + SelectionContainer { + Text( + text = parseAnsiColors(line), + color = Zouge, + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + softWrap = false + ) + } + } + } + } + } + + // Special keys bar + SpecialKeysRow(onSendKey = { viewModel.sendCommand(it) }) + + // Input area + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = inputTextValue, + onValueChange = { inputTextValue = it }, + modifier = Modifier.weight(1f), + placeholder = { Text("コマンドを入力", color = TextMuted) }, + singleLine = !isInputExpanded, + maxLines = if (isInputExpanded) 6 else 1, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = Zouge, + unfocusedTextColor = Zouge, + focusedBorderColor = BorderFocus, + unfocusedBorderColor = BorderStandard, + cursorColor = Kinpaku, + focusedContainerColor = Surface4, + unfocusedContainerColor = Surface4, + ) + ) + + // Expand/collapse text button + IconButton( + onClick = { isInputExpanded = !isInputExpanded }, + modifier = Modifier.size(36.dp) + ) { + Icon( + imageVector = if (isInputExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = "展開", + tint = Kinpaku + ) + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + // BGM toggle button — cycles through 3 tracks + OFF + IconButton(onClick = onBgmToggle) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + imageVector = if (isBgmPlaying) Icons.Default.VolumeUp else Icons.Default.VolumeOff, + contentDescription = "BGM", + tint = if (isBgmPlaying) Kinpaku else TextMuted + ) + if (isBgmPlaying && bgmTrackLabel.isNotEmpty()) { + Text( + text = bgmTrackLabel, + color = Kinpaku, + fontSize = 8.sp + ) + } + } + } + + // Voice input button (manual ON/OFF — stays on until user taps again) + IconButton( + onClick = { + if (speechRecognizer == null) return@IconButton + if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) + == PackageManager.PERMISSION_GRANTED + ) { + if (isListening) { + speechRecognizer.cancel() + isListening = false + } else { + startContinuousListening(speechRecognizer, { isListening }) { result -> + val newText = if (inputTextValue.text.isEmpty()) result else "${inputTextValue.text} $result" + inputTextValue = TextFieldValue(text = newText, selection = TextRange(newText.length)) + } + isListening = true + } + } else { + permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + } + ) { + Icon( + imageVector = Icons.Default.Mic, + contentDescription = "音声入力", + tint = if (isListening) Kurenai else Kinpaku + ) + } + + // Send button + IconButton( + onClick = { + if (inputTextValue.text.isNotBlank()) { + viewModel.sendCommand(inputTextValue.text) + inputTextValue = TextFieldValue("") + } + }, + enabled = inputTextValue.text.isNotBlank() && isConnected && !isListening + ) { + Icon( + imageVector = Icons.Default.Send, + contentDescription = "送信", + tint = if (inputTextValue.text.isNotBlank() && isConnected && !isListening) Kinpaku else TextMuted + ) + } + } // Row (buttons) + } // Column (input area) + } // Column (main) + } // Box +} + +@Composable +fun SpecialKeysRow(onSendKey: (String) -> Unit) { + // Ordered by usage frequency for tmux + Claude Code workflow + val specialKeys = listOf( + "↵" to "\n", // Enter — most used (confirm commands, send input) + "C-c" to "\u0003", // Interrupt — stop running process + "C-b" to "\u0002", // tmux prefix — pane control (C-b C-b for background) + "↑" to "\u001b[A", // History up + "↓" to "\u001b[B", // History down + "Tab" to "\t", // Autocomplete + "ESC" to "\u001b", // Cancel / exit mode + "C-o" to "\u000f", // Accept line in Claude Code + "C-d" to "\u0004" // EOF / exit + ) + LazyRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(specialKeys) { (label, value) -> + OutlinedButton( + onClick = { onSendKey(value) }, + modifier = Modifier.height(32.dp), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp), + border = BorderStroke(1.dp, BorderFocus), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = Surface4, + contentColor = Zouge + ) + ) { + Text( + text = label, + fontSize = 11.sp, + fontFamily = FontFamily.Monospace + ) + } + } + } +} + +/** + * Continuous listening — auto-restarts after each result. + * Checks isActive() before restarting to respect user's OFF toggle. + * Caller should use cancel() (not stopListening()) to stop cleanly. + */ +fun startContinuousListening( + speechRecognizer: SpeechRecognizer, + isActive: () -> Boolean, + onResult: (String) -> Unit +) { + val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + putExtra(RecognizerIntent.EXTRA_LANGUAGE, "ja-JP") + putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1) + putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS, 5000L) + putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS, 5000L) + putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_MINIMUM_LENGTH_MILLIS, 2000L) + } + speechRecognizer.setRecognitionListener(object : RecognitionListener { + override fun onReadyForSpeech(params: Bundle?) {} + override fun onBeginningOfSpeech() {} + override fun onRmsChanged(rmsdB: Float) {} + override fun onBufferReceived(buffer: ByteArray?) {} + override fun onEndOfSpeech() {} + override fun onError(error: Int) { + if (!isActive()) return + when (error) { + SpeechRecognizer.ERROR_AUDIO, + SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> { + // Fatal — do not restart + } + else -> { + android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ + if (isActive()) { + try { speechRecognizer.startListening(intent) } catch (_: Exception) {} + } + }, 300) + } + } + } + override fun onResults(results: Bundle?) { + val matches = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) + if (!matches.isNullOrEmpty()) { + onResult(matches[0]) + } + if (isActive()) { + speechRecognizer.startListening(intent) + } + } + override fun onPartialResults(partialResults: Bundle?) {} + override fun onEvent(eventType: Int, params: Bundle?) {} + }) + speechRecognizer.startListening(intent) +} diff --git a/android/app/src/main/java/com/shogun/android/util/Constants.kt b/android/app/src/main/java/com/shogun/android/util/Constants.kt index 8705304b9..6d1597046 100644 --- a/android/app/src/main/java/com/shogun/android/util/Constants.kt +++ b/android/app/src/main/java/com/shogun/android/util/Constants.kt @@ -1,32 +1,34 @@ -package com.shogun.android.util - -/** SharedPreferences keys — single source of truth to prevent typo bugs. */ -object PrefsKeys { - const val PREFS_NAME = "shogun_prefs" - const val SSH_HOST = "ssh_host" - const val SSH_PORT = "ssh_port" - const val SSH_USER = "ssh_user" - const val SSH_KEY_PATH = "ssh_key_path" - const val SSH_PASSWORD = "ssh_password" - const val PROJECT_PATH = "project_path" - const val SHOGUN_SESSION = "shogun_session" - const val AGENTS_SESSION = "agents_session" - const val NOTIFICATION_ENABLED = "notification_enabled" - const val NTFY_TOPIC = "ntfy_topic" - const val NOTIFY_CMD_COMPLETE = "notify_cmd_complete" - const val NOTIFY_CMD_FAILURE = "notify_cmd_failure" - const val NOTIFY_ACTION_REQUIRED = "notify_action_required" - const val NOTIFY_DASHBOARD_UPDATE = "notify_dashboard_update" - const val NOTIFY_STREAK_UPDATE = "notify_streak_update" - const val NOTIFY_AGENT_RESPONSE = "notify_agent_response" -} - -object Defaults { - const val SSH_HOST = "192.168.1.1" - const val SSH_PORT = 22 - const val SSH_PORT_STR = "22" - const val SHOGUN_SESSION = "shogun" - const val AGENTS_SESSION = "multiagent" - const val NTFY_TOPIC = "sho-y0uhey" - const val TMUX = "/usr/bin/tmux" -} +package com.shogun.android.util + +/** SharedPreferences keys — single source of truth to prevent typo bugs. */ +object PrefsKeys { + const val PREFS_NAME = "shogun_prefs" + const val SSH_HOST = "ssh_host" + const val SSH_PORT = "ssh_port" + const val SSH_USER = "ssh_user" + const val SSH_KEY_PATH = "ssh_key_path" + const val SSH_PASSWORD = "ssh_password" + const val PROJECT_PATH = "project_path" + const val SHOGUN_SESSION = "shogun_session" + const val AGENTS_SESSION = "agents_session" + const val PROFILES_JSON = "profiles_json" + const val ACTIVE_PROFILE_ID = "active_profile_id" + const val NOTIFICATION_ENABLED = "notification_enabled" + const val NTFY_TOPIC = "ntfy_topic" + const val NOTIFY_CMD_COMPLETE = "notify_cmd_complete" + const val NOTIFY_CMD_FAILURE = "notify_cmd_failure" + const val NOTIFY_ACTION_REQUIRED = "notify_action_required" + const val NOTIFY_DASHBOARD_UPDATE = "notify_dashboard_update" + const val NOTIFY_STREAK_UPDATE = "notify_streak_update" + const val NOTIFY_AGENT_RESPONSE = "notify_agent_response" +} + +object Defaults { + const val SSH_HOST = "192.168.1.1" + const val SSH_PORT = 22 + const val SSH_PORT_STR = "22" + const val SHOGUN_SESSION = "shogun" + const val AGENTS_SESSION = "multiagent" + const val NTFY_TOPIC = "sho-y0uhey" + const val TMUX = "/usr/bin/tmux" +} diff --git a/android/app/src/main/java/com/shogun/android/util/EncryptedPrefsProvider.kt b/android/app/src/main/java/com/shogun/android/util/EncryptedPrefsProvider.kt new file mode 100644 index 000000000..1330a2866 --- /dev/null +++ b/android/app/src/main/java/com/shogun/android/util/EncryptedPrefsProvider.kt @@ -0,0 +1,23 @@ +package com.shogun.android.util + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey + +private const val ENCRYPTED_PREFS_NAME = "shogun_encrypted_prefs" + +object EncryptedPrefsProvider { + fun getPreferences(context: Context): SharedPreferences { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + return EncryptedSharedPreferences.create( + context, + ENCRYPTED_PREFS_NAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } +} diff --git a/android/app/src/main/java/com/shogun/android/util/PreferencesMigration.kt b/android/app/src/main/java/com/shogun/android/util/PreferencesMigration.kt new file mode 100644 index 000000000..cca06fc61 --- /dev/null +++ b/android/app/src/main/java/com/shogun/android/util/PreferencesMigration.kt @@ -0,0 +1,36 @@ +package com.shogun.android.util + +import android.content.Context + +private const val MIGRATION_FLAG = "encrypted_migration_v1" + +object PreferencesMigration { + fun migrateIfNeeded(context: Context) { + val encryptedPrefs = EncryptedPrefsProvider.getPreferences(context) + if (encryptedPrefs.contains(MIGRATION_FLAG)) return + + val oldPrefs = context.getSharedPreferences(PrefsKeys.PREFS_NAME, Context.MODE_PRIVATE) + val oldAll = oldPrefs.all + + val editor = encryptedPrefs.edit() + for ((key, value) in oldAll) { + when (value) { + is String -> editor.putString(key, value) + is Boolean -> editor.putBoolean(key, value) + is Int -> editor.putInt(key, value) + is Long -> editor.putLong(key, value) + is Float -> editor.putFloat(key, value) + is Set<*> -> { + @Suppress("UNCHECKED_CAST") + editor.putStringSet(key, value as Set) + } + } + } + editor.putBoolean(MIGRATION_FLAG, true) + editor.commit() + + if (oldAll.isNotEmpty()) { + oldPrefs.edit().clear().commit() + } + } +} diff --git a/android/app/src/main/java/com/shogun/android/viewmodel/AgentsViewModel.kt b/android/app/src/main/java/com/shogun/android/viewmodel/AgentsViewModel.kt index 2949cc8e9..1fb441f4c 100644 --- a/android/app/src/main/java/com/shogun/android/viewmodel/AgentsViewModel.kt +++ b/android/app/src/main/java/com/shogun/android/viewmodel/AgentsViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.shogun.android.ssh.SshManager import com.shogun.android.util.Defaults +import com.shogun.android.util.EncryptedPrefsProvider import com.shogun.android.util.PrefsKeys import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -24,7 +25,7 @@ data class PaneInfo( class AgentsViewModel(application: Application) : AndroidViewModel(application) { private val sshManager = SshManager.getInstance() - private val prefs = application.getSharedPreferences(PrefsKeys.PREFS_NAME, Context.MODE_PRIVATE) + private val prefs = EncryptedPrefsProvider.getPreferences(application) private val _panes = MutableStateFlow>(emptyList()) val panes: StateFlow> = _panes diff --git a/android/app/src/main/java/com/shogun/android/viewmodel/DashboardViewModel.kt b/android/app/src/main/java/com/shogun/android/viewmodel/DashboardViewModel.kt index bcdcff9f6..fb96fe939 100644 --- a/android/app/src/main/java/com/shogun/android/viewmodel/DashboardViewModel.kt +++ b/android/app/src/main/java/com/shogun/android/viewmodel/DashboardViewModel.kt @@ -1,67 +1,85 @@ -package com.shogun.android.viewmodel - -import android.app.Application -import android.content.Context -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.viewModelScope -import com.shogun.android.ssh.SshManager -import com.shogun.android.util.PrefsKeys -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch - -class DashboardViewModel(application: Application) : AndroidViewModel(application) { - - private val sshManager = SshManager.getInstance() - private val prefs = application.getSharedPreferences(PrefsKeys.PREFS_NAME, Context.MODE_PRIVATE) - - private val _markdownContent = MutableStateFlow("") - val markdownContent: StateFlow = _markdownContent - - private val _isConnected = MutableStateFlow(false) - val isConnected: StateFlow = _isConnected - - private val _isLoading = MutableStateFlow(false) - val isLoading: StateFlow = _isLoading - - private val _errorMessage = MutableStateFlow(null) - val errorMessage: StateFlow = _errorMessage - - fun connect(host: String, port: Int, user: String, keyPath: String, password: String = "") { - viewModelScope.launch { - val result = sshManager.connect(host, port, user, keyPath, password) - if (result.isSuccess) { - _isConnected.value = true - loadDashboard() - } else { - _errorMessage.value = "接続失敗: ${result.exceptionOrNull()?.message}" - } - } - } - - fun loadDashboard() { - viewModelScope.launch { - _isLoading.value = true - val projectPath = prefs.getString(PrefsKeys.PROJECT_PATH, "") ?: "" - if (projectPath.isBlank()) { - _errorMessage.value = "設定画面でプロジェクトパスを設定してください" - _isLoading.value = false - return@launch - } - val result = sshManager.execCommand("cat $projectPath/dashboard.md") - if (result.isSuccess) { - _markdownContent.value = result.getOrDefault("") - _errorMessage.value = null - } else { - _errorMessage.value = result.exceptionOrNull()?.message - } - _isLoading.value = false - } - } - - override fun onCleared() { - super.onCleared() - // Do NOT disconnect the shared singleton SshManager here. - // Tab navigation triggers onCleared, killing the connection for all ViewModels. - } -} +package com.shogun.android.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.shogun.android.data.ProfileRepository +import com.shogun.android.ssh.SshManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class DashboardViewModel( + application: Application, + private val profileRepository: ProfileRepository +) : AndroidViewModel(application) { + + private val sshManager = SshManager.getInstance() + + private val _markdownContent = MutableStateFlow("") + val markdownContent: StateFlow = _markdownContent + + private val _isConnected = MutableStateFlow(false) + val isConnected: StateFlow = _isConnected + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading + + private val _errorMessage = MutableStateFlow(null) + val errorMessage: StateFlow = _errorMessage + + fun connect() { + viewModelScope.launch { + val activeId = profileRepository.getActiveProfileId() + val profiles = profileRepository.loadProfiles() + val activeProfile = profiles.find { it.id == activeId } ?: profiles.firstOrNull() + if (activeProfile == null) { + _errorMessage.value = "プロファイルが設定されていません。設定画面でプロファイルを作成してください" + return@launch + } + val result = sshManager.connect( + activeProfile.sshHost, + activeProfile.sshPort, + activeProfile.sshUser, + activeProfile.sshKeyPath, + activeProfile.sshPassword + ) + if (result.isSuccess) { + _isConnected.value = true + loadDashboard() + } else { + _errorMessage.value = "接続失敗: ${result.exceptionOrNull()?.message}" + } + } + } + + fun loadDashboard() { + viewModelScope.launch { + _isLoading.value = true + val activeId = profileRepository.getActiveProfileId() + val profiles = profileRepository.loadProfiles() + val activeProfile = profiles.find { it.id == activeId } ?: profiles.firstOrNull() + val projectPath = activeProfile?.projectPath ?: "" + val dashboardFile = activeProfile?.dashboardFileName ?: "dashboard.md" + if (projectPath.isBlank()) { + _errorMessage.value = "設定画面でプロジェクトパスを設定してください" + _isLoading.value = false + return@launch + } + val result = sshManager.execCommand("cat $projectPath/$dashboardFile") + if (result.isSuccess) { + _markdownContent.value = result.getOrDefault("") + _errorMessage.value = null + } else { + _errorMessage.value = result.exceptionOrNull()?.message + } + _isLoading.value = false + } + } + + override fun onCleared() { + super.onCleared() + // Do NOT disconnect the shared singleton SshManager here. + // Tab navigation triggers onCleared, killing the connection for all ViewModels. + } +} diff --git a/android/app/src/main/java/com/shogun/android/viewmodel/DashboardViewModelFactory.kt b/android/app/src/main/java/com/shogun/android/viewmodel/DashboardViewModelFactory.kt new file mode 100644 index 000000000..56aa2dfb6 --- /dev/null +++ b/android/app/src/main/java/com/shogun/android/viewmodel/DashboardViewModelFactory.kt @@ -0,0 +1,22 @@ +package com.shogun.android.viewmodel + +import android.app.Application +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.shogun.android.data.ProfileRepository +import com.shogun.android.data.SharedPreferencesProfileRepository +import com.shogun.android.util.EncryptedPrefsProvider +import com.shogun.android.util.PrefsKeys + +class DashboardViewModelFactory( + private val application: Application +) : ViewModelProvider.Factory { + + override fun create(modelClass: Class): T { + val prefs = EncryptedPrefsProvider.getPreferences(application) + val repository: ProfileRepository = SharedPreferencesProfileRepository(prefs) + @Suppress("UNCHECKED_CAST") + return DashboardViewModel(application, repository) as T + } +} diff --git a/android/app/src/main/java/com/shogun/android/viewmodel/ProfileViewModel.kt b/android/app/src/main/java/com/shogun/android/viewmodel/ProfileViewModel.kt new file mode 100644 index 000000000..018f8dc03 --- /dev/null +++ b/android/app/src/main/java/com/shogun/android/viewmodel/ProfileViewModel.kt @@ -0,0 +1,104 @@ +package com.shogun.android.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.shogun.android.data.Profile +import com.shogun.android.data.ProfileRepository +import com.shogun.android.data.SharedPreferencesProfileRepository +import com.shogun.android.util.EncryptedPrefsProvider +import com.shogun.android.util.PrefsKeys +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.UUID + +class ProfileViewModel( + application: Application, + private val repository: ProfileRepository +) : AndroidViewModel(application) { + + private val _profiles = MutableStateFlow>(emptyList()) + val profiles: StateFlow> = _profiles.asStateFlow() + + private val _activeProfile = MutableStateFlow(null) + val activeProfile: StateFlow = _activeProfile.asStateFlow() + + init { + val loaded = repository.loadProfiles() + _profiles.value = loaded + val activeId = repository.getActiveProfileId() + _activeProfile.value = loaded.find { it.id == activeId } ?: loaded.firstOrNull() + } + + fun selectProfile(id: String) { + val profile = _profiles.value.find { it.id == id } ?: return + setActiveProfileInternal(profile) + } + + private fun setActiveProfileInternal(profile: Profile?) { + if (profile != null) { + repository.setActiveProfileId(profile.id) + syncToPrefs(profile) + } + _activeProfile.value = profile + } + + private fun syncToPrefs(profile: Profile) { + val prefs = EncryptedPrefsProvider.getPreferences(getApplication()) + prefs.edit() + .putString(PrefsKeys.SSH_HOST, profile.sshHost) + .putString(PrefsKeys.SSH_PORT, profile.sshPort.toString()) + .putString(PrefsKeys.SSH_USER, profile.sshUser) + .putString(PrefsKeys.SSH_KEY_PATH, profile.sshKeyPath) + .putString(PrefsKeys.SSH_PASSWORD, profile.sshPassword) + .putString(PrefsKeys.PROJECT_PATH, profile.projectPath) + .putString(PrefsKeys.SHOGUN_SESSION, profile.shogunSession) + .putString(PrefsKeys.AGENTS_SESSION, profile.agentsSession) + .commit() + } + + fun addProfile(profile: Profile) { + repository.addProfile(profile) + _profiles.value = repository.loadProfiles() + } + + fun duplicateProfile(sourceId: String, newName: String): Profile? { + val source = _profiles.value.find { it.id == sourceId } ?: return null + val copy = source.copy(id = UUID.randomUUID().toString(), name = newName) + addProfile(copy) + return copy + } + + fun deleteProfile(id: String) { + repository.deleteProfile(id) + val updated = repository.loadProfiles() + _profiles.value = updated + if (_activeProfile.value?.id == id) { + setActiveProfileInternal(updated.firstOrNull()) + } + } + + fun updateProfile(profile: Profile) { + repository.updateProfile(profile) + _profiles.value = repository.loadProfiles() + if (_activeProfile.value?.id == profile.id) { + setActiveProfileInternal(profile) + } + } + + companion object { + fun factory(application: Application): ViewModelProvider.Factory = + object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + val prefs = EncryptedPrefsProvider.getPreferences(application) + @Suppress("UNCHECKED_CAST") + return ProfileViewModel( + application, + SharedPreferencesProfileRepository(prefs) + ) as T + } + } + } +} diff --git a/android/app/src/main/java/com/shogun/android/viewmodel/SettingsViewModel.kt b/android/app/src/main/java/com/shogun/android/viewmodel/SettingsViewModel.kt index fe17b96e8..cc47afd9b 100644 --- a/android/app/src/main/java/com/shogun/android/viewmodel/SettingsViewModel.kt +++ b/android/app/src/main/java/com/shogun/android/viewmodel/SettingsViewModel.kt @@ -4,13 +4,14 @@ import android.app.Application import android.content.Context import androidx.lifecycle.AndroidViewModel import com.shogun.android.util.Defaults +import com.shogun.android.util.EncryptedPrefsProvider import com.shogun.android.util.PrefsKeys import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow class SettingsViewModel(application: Application) : AndroidViewModel(application) { - private val prefs = application.getSharedPreferences(PrefsKeys.PREFS_NAME, Context.MODE_PRIVATE) + private val prefs = EncryptedPrefsProvider.getPreferences(application) private val _notificationEnabled = MutableStateFlow(prefs.getBoolean(PrefsKeys.NOTIFICATION_ENABLED, true)) val notificationEnabled: StateFlow = _notificationEnabled diff --git a/android/app/src/main/java/com/shogun/android/viewmodel/ShogunViewModel.kt b/android/app/src/main/java/com/shogun/android/viewmodel/ShogunViewModel.kt index 2050ccd47..3f9b2a33f 100644 --- a/android/app/src/main/java/com/shogun/android/viewmodel/ShogunViewModel.kt +++ b/android/app/src/main/java/com/shogun/android/viewmodel/ShogunViewModel.kt @@ -1,153 +1,154 @@ -package com.shogun.android.viewmodel - -import android.app.Application -import android.content.Context -import android.content.Intent -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.viewModelScope -import com.shogun.android.SshForegroundService -import com.shogun.android.ssh.SshManager -import com.shogun.android.util.Defaults -import com.shogun.android.util.PrefsKeys -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch - -class ShogunViewModel(application: Application) : AndroidViewModel(application) { - - private val sshManager = SshManager.getInstance() - private val prefs = application.getSharedPreferences(PrefsKeys.PREFS_NAME, Context.MODE_PRIVATE) - - private val _paneContent = MutableStateFlow("") - val paneContent: StateFlow = _paneContent - - private val _isConnected = MutableStateFlow(false) - val isConnected: StateFlow = _isConnected - - private val _errorMessage = MutableStateFlow(null) - val errorMessage: StateFlow = _errorMessage - - private var refreshJob: Job? = null - private var reconnectJob: Job? = null - @Volatile private var paused = false - - private fun tmuxTarget(): String { - val session = prefs.getString(PrefsKeys.SHOGUN_SESSION, Defaults.SHOGUN_SESSION) ?: Defaults.SHOGUN_SESSION - return "$session:main" - } - - fun pauseRefresh() { paused = true } - fun resumeRefresh() { - paused = false - viewModelScope.launch { - if (sshManager.isConnected()) { - val result = sshManager.execCommand("${Defaults.TMUX} capture-pane -t ${tmuxTarget()} -p -e -S -500") - if (result.isSuccess) { - _paneContent.value = result.getOrDefault("") - _errorMessage.value = null - } - } - } - } - - fun connect(host: String, port: Int, user: String, keyPath: String, password: String = "") { - viewModelScope.launch { - val result = sshManager.connect( - host, port, user, keyPath, password, - onDisconnect = { - _isConnected.value = false - startReconnect() - } - ) - if (result.isSuccess) { - _isConnected.value = true - _errorMessage.value = null - startForegroundService() - startAutoRefresh() - } else { - _errorMessage.value = "接続失敗: ${result.exceptionOrNull()?.message}" - } - } - } - - private fun startAutoRefresh() { - refreshJob?.cancel() - refreshJob = viewModelScope.launch { - while (isActive) { - if (!paused && sshManager.isConnected()) { - val result = sshManager.execCommand("${Defaults.TMUX} capture-pane -t ${tmuxTarget()} -p -e -S -500") - if (result.isSuccess) { - _paneContent.value = result.getOrDefault("") - _errorMessage.value = null - } else { - _errorMessage.value = result.exceptionOrNull()?.message - } - } - delay(3000) - } - } - } - - fun sendCommand(text: String) { - viewModelScope.launch { - val target = tmuxTarget() - val escaped = text.replace("'", "'\\''") - // Send text and Enter SEPARATELY with 0.3s gap (Claude Code requirement) - sshManager.execCommand("${Defaults.TMUX} send-keys -t $target '$escaped'") - delay(300) - sshManager.execCommand("${Defaults.TMUX} send-keys -t $target Enter") - delay(1500) - if (sshManager.isConnected()) { - val result = sshManager.execCommand("${Defaults.TMUX} capture-pane -t $target -p -e -S -500") - if (result.isSuccess) { - _paneContent.value = result.getOrDefault("") - } - } - } - } - - private fun startReconnect() { - reconnectJob?.cancel() - reconnectJob = viewModelScope.launch { - _paneContent.value += "\n[自動再接続中...]\n" - val result = sshManager.reconnect(maxAttempts = 3, delayMs = 5000) - if (result.isSuccess) { - _isConnected.value = true - _errorMessage.value = null - _paneContent.value += "[再接続成功]\n" - startForegroundService() - startAutoRefresh() - } else { - _isConnected.value = false - _errorMessage.value = "再接続失敗: ${result.exceptionOrNull()?.message}" - _paneContent.value += "[再接続失敗。手動で再接続してください]\n" - stopForegroundService() - } - } - } - - private fun startForegroundService() { - try { - val ctx = getApplication() - val intent = Intent(ctx, SshForegroundService::class.java) - ctx.startForegroundService(intent) - } catch (_: Exception) { - // Foreground service start blocked by system — SSH works without it - } - } - - private fun stopForegroundService() { - val ctx = getApplication() - val intent = Intent(ctx, SshForegroundService::class.java) - ctx.stopService(intent) - } - - override fun onCleared() { - super.onCleared() - refreshJob?.cancel() - reconnectJob?.cancel() - } -} +package com.shogun.android.viewmodel + +import android.app.Application +import android.content.Context +import android.content.Intent +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.shogun.android.SshForegroundService +import com.shogun.android.ssh.SshManager +import com.shogun.android.util.Defaults +import com.shogun.android.util.EncryptedPrefsProvider +import com.shogun.android.util.PrefsKeys +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +class ShogunViewModel(application: Application) : AndroidViewModel(application) { + + private val sshManager = SshManager.getInstance() + private val prefs = EncryptedPrefsProvider.getPreferences(application) + + private val _paneContent = MutableStateFlow("") + val paneContent: StateFlow = _paneContent + + private val _isConnected = MutableStateFlow(false) + val isConnected: StateFlow = _isConnected + + private val _errorMessage = MutableStateFlow(null) + val errorMessage: StateFlow = _errorMessage + + private var refreshJob: Job? = null + private var reconnectJob: Job? = null + @Volatile private var paused = false + + private fun tmuxTarget(): String { + val session = prefs.getString(PrefsKeys.SHOGUN_SESSION, Defaults.SHOGUN_SESSION) ?: Defaults.SHOGUN_SESSION + return "$session:0" + } + + fun pauseRefresh() { paused = true } + fun resumeRefresh() { + paused = false + viewModelScope.launch { + if (sshManager.isConnected()) { + val result = sshManager.execCommand("${Defaults.TMUX} capture-pane -t ${tmuxTarget()} -p -e -S -500") + if (result.isSuccess) { + _paneContent.value = result.getOrDefault("") + _errorMessage.value = null + } + } + } + } + + fun connect(host: String, port: Int, user: String, keyPath: String, password: String = "") { + viewModelScope.launch { + val result = sshManager.connect( + host, port, user, keyPath, password, + onDisconnect = { + _isConnected.value = false + startReconnect() + } + ) + if (result.isSuccess) { + _isConnected.value = true + _errorMessage.value = null + startForegroundService() + startAutoRefresh() + } else { + _errorMessage.value = "接続失敗: ${result.exceptionOrNull()?.message}" + } + } + } + + private fun startAutoRefresh() { + refreshJob?.cancel() + refreshJob = viewModelScope.launch { + while (isActive) { + if (!paused && sshManager.isConnected()) { + val result = sshManager.execCommand("${Defaults.TMUX} capture-pane -t ${tmuxTarget()} -p -e -S -500") + if (result.isSuccess) { + _paneContent.value = result.getOrDefault("") + _errorMessage.value = null + } else { + _errorMessage.value = result.exceptionOrNull()?.message + } + } + delay(3000) + } + } + } + + fun sendCommand(text: String) { + viewModelScope.launch { + val target = tmuxTarget() + val escaped = text.replace("'", "'\\''") + // Send text and Enter SEPARATELY with 0.3s gap (Claude Code requirement) + sshManager.execCommand("${Defaults.TMUX} send-keys -t $target '$escaped'") + delay(300) + sshManager.execCommand("${Defaults.TMUX} send-keys -t $target Enter") + delay(1500) + if (sshManager.isConnected()) { + val result = sshManager.execCommand("${Defaults.TMUX} capture-pane -t $target -p -e -S -500") + if (result.isSuccess) { + _paneContent.value = result.getOrDefault("") + } + } + } + } + + private fun startReconnect() { + reconnectJob?.cancel() + reconnectJob = viewModelScope.launch { + _paneContent.value += "\n[自動再接続中...]\n" + val result = sshManager.reconnect(maxAttempts = 3, delayMs = 5000) + if (result.isSuccess) { + _isConnected.value = true + _errorMessage.value = null + _paneContent.value += "[再接続成功]\n" + startForegroundService() + startAutoRefresh() + } else { + _isConnected.value = false + _errorMessage.value = "再接続失敗: ${result.exceptionOrNull()?.message}" + _paneContent.value += "[再接続失敗。手動で再接続してください]\n" + stopForegroundService() + } + } + } + + private fun startForegroundService() { + try { + val ctx = getApplication() + val intent = Intent(ctx, SshForegroundService::class.java) + ctx.startForegroundService(intent) + } catch (_: Exception) { + // Foreground service start blocked by system — SSH works without it + } + } + + private fun stopForegroundService() { + val ctx = getApplication() + val intent = Intent(ctx, SshForegroundService::class.java) + ctx.stopService(intent) + } + + override fun onCleared() { + super.onCleared() + refreshJob?.cancel() + reconnectJob?.cancel() + } +} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 39fd79535..a7b482132 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -13,6 +13,7 @@ navigationCompose = "2.7.7" jsch = "0.2.21" markwon = "4.6.2" accompanistSwiperefresh = "0.32.0" +securityCrypto = "1.1.0-alpha06" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -36,6 +37,7 @@ jsch = { group = "com.github.mwiede", name = "jsch", version.ref = "jsch" } markwon-core = { group = "io.noties.markwon", name = "core", version.ref = "markwon" } markwon-ext-tables = { group = "io.noties.markwon", name = "ext-tables", version.ref = "markwon" } accompanist-swiperefresh = { group = "com.google.accompanist", name = "accompanist-swiperefresh", version.ref = "accompanistSwiperefresh" } +androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/android/gradlew b/android/gradlew old mode 100644 new mode 100755