Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -242,3 +242,41 @@ dependencies {
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}

// The base jacoco plugin only wires a report task to the JVM `test` task, which an Android module
// does not have — so `:app:jacocoTestReport` did not exist and CI's coverage step failed outright.
tasks.register<JacocoReport>("jacocoTestReport") {
dependsOn("testDebugUnitTest")
group = "verification"
description = "Generates a coverage report for the debug unit tests."

reports {
html.required.set(true)
xml.required.set(true)
}

val generated =
listOf(
"**/R.class",
"**/R$*.class",
"**/BuildConfig.*",
"**/Manifest*.*",
"**/*Test*.*",
"**/*_Factory*.*",
"**/*Composable*.*",
"**/*\$\$serializer.*",
)
classDirectories.setFrom(
fileTree(layout.buildDirectory.dir("tmp/kotlin-classes/debug")) { exclude(generated) },
)
sourceDirectories.setFrom(files("src/main/java"))
// Only the unit test coverage directories, never the whole build directory: scanning that
// makes Gradle treat every other task's output as an undeclared input of this one, which fails
// the build as soon as a release variant is assembled in the same invocation.
executionData.setFrom(
fileTree(layout.buildDirectory.dir("jacoco")) { include("*.exec") },
fileTree(layout.buildDirectory.dir("outputs/unit_test_code_coverage")) {
include("**/*.exec")
},
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import com.opencode.android.feature.workspace.RemoteConnectionScreen
import com.opencode.android.runtime.LocalRuntimeStatus
import com.opencode.android.runtime.RuntimeType
import com.opencode.android.runtime.WorkspaceRef
import com.opencode.android.ui.components.SessionStatus
import com.opencode.android.ui.theme.OpenCodeAndroidTheme
import org.junit.Rule
import org.junit.Test
Expand Down Expand Up @@ -128,13 +129,29 @@ class UiScreenshotInstrumentedTest {
AppDrawerContent(
recentSessions =
listOf(
DrawerRecentSession("1", "認証エラーの調査", "3時間前"),
DrawerRecentSession("2", "READMEを更新", "昨日"),
DrawerRecentSession(
"1",
"認証エラーの調査",
"3時間前",
status = SessionStatus.RUNNING,
),
DrawerRecentSession(
"2",
"READMEを更新",
"昨日",
status = SessionStatus.COMPLETED_UNREAD,
),
DrawerRecentSession("3", "テスト失敗を修正", "2日前"),
DrawerRecentSession("4", "APIレスポンスを整理", "4日前"),
DrawerRecentSession("5", "依存関係を更新", "1週間前"),
),
workspaces =
listOf(
WorkspaceRef("1", "project", "/workspace/project"),
),
selectedWorkspacePath = "/workspace/project",
onNewChat = {},
onSelectProject = {},
onOpenSession = { _, _ -> },
onNavigate = {},
)
Expand Down Expand Up @@ -271,8 +288,10 @@ class UiScreenshotInstrumentedTest {
VoiceSettingsScreen(
ttsEnabled = true,
continuousConversation = false,
wakeWordEnabled = false,
onTtsChange = {},
onContinuousChange = {},
onWakeWordChange = {},
onBack = {},
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ class OpenCodeApplication : Application() {
onPermissionAsked = notifications::notifyPermission,
onSessionIdle = notifications::notifySessionComplete,
onSessionError = notifications::notifySessionError,
unreadStore = settings,
)
scheduleDeferredInitialization()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.opencode.android.data.repository.UnreadSessionStore
import com.opencode.android.runtime.RuntimeConnectionStore

class SecureSettingsRepository(context: Context) : RuntimeConnectionStore {
class SecureSettingsRepository(context: Context) : RuntimeConnectionStore, UnreadSessionStore {
private val masterKey =
MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
Expand Down Expand Up @@ -180,6 +181,21 @@ class SecureSettingsRepository(context: Context) : RuntimeConnectionStore {
get() = preferences.getBoolean(KEY_ONBOARDING_COMPLETED, false)
set(value) = preferences.edit().putBoolean(KEY_ONBOARDING_COMPLETED, value).apply()

/** Chats that finished without being read, kept across restarts for the drawer markers. */
override var unreadSessionIds: Set<String>
get() =
preferences
.getStringSet(KEY_UNREAD_SESSIONS, emptySet())
.orEmpty()
.filter { it.isNotBlank() }
.toSet()
set(value) {
preferences
.edit()
.putStringSet(KEY_UNREAD_SESSIONS, value.filter { it.isNotBlank() }.toSet())
.apply()
}

var githubToken: String?
get() = preferences.getString(KEY_GITHUB_TOKEN, null)
set(value) = preferences.edit().putString(KEY_GITHUB_TOKEN, value).apply()
Expand Down Expand Up @@ -259,6 +275,7 @@ class SecureSettingsRepository(context: Context) : RuntimeConnectionStore {
private const val KEY_SAF_WORKSPACE_URIS = "saf_workspace_uris"
private const val KEY_PROJECT_PATHS = "project_paths"
private const val KEY_ONBOARDING_COMPLETED = "onboarding_completed"
private const val KEY_UNREAD_SESSIONS = "unread_sessions"
private const val KEY_GITHUB_TOKEN = "github_token"
private const val KEY_GITHUB_LOGIN = "github_login"
private const val KEY_THEME = "theme"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import com.opencode.android.runtime.RuntimeRegistry
import com.opencode.android.runtime.RuntimeState
import com.opencode.android.runtime.RuntimeTarget
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
Expand All @@ -29,6 +28,11 @@ data class RuntimeEventLog(
val sessionId: String? = null,
)

/** Persists which chats finished without the user having read them. */
interface UnreadSessionStore {
var unreadSessionIds: Set<String>
}

data class RuntimeActivityState(
val activeSessionIds: Set<String> = emptySet(),
val completedSessionIds: Set<String> = emptySet(),
Expand All @@ -45,13 +49,19 @@ class RuntimeActivityRepository(
private val onPermissionAsked: ((PermissionRequest) -> Unit)? = null,
private val onSessionIdle: ((String) -> Unit)? = null,
private val onSessionError: ((String?, String?) -> Unit)? = null,
private val unreadStore: UnreadSessionStore? = null,
) {
init {
require(retryDelayMillis >= 0L)
require(maxRetryDelayMillis >= retryDelayMillis)
}

private val mutableState = MutableStateFlow(RuntimeActivityState())
// Unread markers outlive the process: a chat that finished while the app was closed is still
// unread the next time the drawer is opened.
private val mutableState =
MutableStateFlow(
RuntimeActivityState(completedSessionIds = unreadStore?.unreadSessionIds.orEmpty()),
)
val state: StateFlow<RuntimeActivityState> = mutableState.asStateFlow()

private val mutableEvents = MutableSharedFlow<OpenCodeEvent>(extraBufferCapacity = 128)
Expand All @@ -60,33 +70,27 @@ class RuntimeActivityRepository(
init {
scope.launch {
registry.selected.collectLatest selected@{ target ->
mutableState.value = RuntimeActivityState()
mutableState.value =
RuntimeActivityState(completedSessionIds = mutableState.value.completedSessionIds)
if (target == null) return@selected

// The event stream is kept alive across transient runtime-state churn (e.g. a
// health recheck that briefly reports Connecting). Restarting it on every such
// blip used to drop whole replies on the floor.
// The stream follows the selected runtime, not its connection state.
//
// It used to only open once the runtime reported Connected. Every REST call
// (sending a prompt, loading a transcript) works regardless of that flag, so a
// runtime whose state never reached Connected still looked completely functional
// while silently never delivering a single event: no live reply, no drawer
// activity, no idle notification. Reconnection is handled by the retry below, so
// opening eagerly and retrying is both simpler and strictly more robust.
coroutineScope {
var streamJob: Job? = null
launch { streamEvents(target) }

// Losing the runtime clears in-flight state, but never the unread markers:
// a dropped connection says nothing about what the user has read.
target.state.collect { runtimeState ->
when (runtimeState) {
is RuntimeState.Connected -> {
if (streamJob?.isActive != true) {
streamJob = launch { streamEvents(target) }
}
}
RuntimeState.Connecting -> Unit
else -> {
streamJob?.cancel()
streamJob = null
mutableState.update {
it.copy(
activeSessionIds = emptySet(),
completedSessionIds = emptySet(),
permissions = emptyList(),
)
}
}
if (runtimeState is RuntimeState.Connected || runtimeState is RuntimeState.Connecting) return@collect
mutableState.update {
it.copy(activeSessionIds = emptySet(), permissions = emptyList())
}
}
}
Expand Down Expand Up @@ -127,6 +131,49 @@ class RuntimeActivityRepository(
mutableState.update { current ->
current.copy(completedSessionIds = current.completedSessionIds - sessionId)
}
persistUnread()
}

/**
* Records a run the app started itself.
*
* Session state used to be derived purely from stream events, so with no event stream every
* chat sat on the grey idle dot even while it was plainly working. The chat reports its own
* runs here so the drawer stays truthful whether or not events are flowing.
*/
fun markSessionRunning(sessionId: String) {
if (sessionId.isBlank()) return
mutableState.update { current ->
current.copy(
activeSessionIds = current.activeSessionIds + sessionId,
completedSessionIds = current.completedSessionIds - sessionId,
)
}
persistUnread()
}

/** Records that a run finished, leaving the chat unread until it is opened. */
fun markSessionFinished(
sessionId: String,
unread: Boolean,
) {
if (sessionId.isBlank()) return
mutableState.update { current ->
current.copy(
activeSessionIds = current.activeSessionIds - sessionId,
completedSessionIds =
if (unread) {
current.completedSessionIds + sessionId
} else {
current.completedSessionIds - sessionId
},
)
}
persistUnread()
}

private fun persistUnread() {
unreadStore?.unreadSessionIds = mutableState.value.completedSessionIds
}

private fun handle(event: OpenCodeEvent) {
Expand Down
Loading
Loading