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
16 changes: 13 additions & 3 deletions app/src/main/assets/scripts/and-code-claude-permission-hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ PENDING="$BRIDGE/pending"
RESPONSES="$BRIDGE/responses"
ALWAYS="$BRIDGE/always-rules.json"
TIMEOUT_SEC="${ANDCODE_PERMISSION_TIMEOUT_SEC:-300}"
# A question can legitimately wait much longer than a permission: the user may be away from the
# device and the turn is blocked until they answer. Keep it under the hook timeout Claude Code
# itself applies (see ClaudePermissionHooks), so this script still gets to deny gracefully.
QUESTION_TIMEOUT_SEC="${ANDCODE_QUESTION_TIMEOUT_SEC:-3540}"
SLEEP_SEC=0.25

mkdir -p "$PENDING" "$RESPONSES"
Expand Down Expand Up @@ -88,8 +92,15 @@ else
printf '%s\n' "{\"v\":1,\"kind\":\"$KIND\",\"requestId\":\"$REQUEST_ID\",\"androidSessionId\":\"$ANDROID_SESSION\",\"claudeSessionId\":\"$SESSION_ID\",\"toolName\":\"$TOOL_NAME\",\"toolInput\":{},\"permissionLabel\":\"$LABEL\",\"createdAtMs\":0}" >"$REQUEST_FILE"
fi

elapsed=0
while [ "$elapsed" -lt "$TIMEOUT_SEC" ]; do
if [ "$KIND" = "question" ]; then
WAIT_SEC="$QUESTION_TIMEOUT_SEC"
else
WAIT_SEC="$TIMEOUT_SEC"
fi
# Measure real seconds: the loop used to count iterations of a quarter-second sleep, which made
# the effective timeout a quarter of the configured one and expired questions far too early.
deadline=$(( $(date +%s) + WAIT_SEC ))
while [ "$(date +%s)" -lt "$deadline" ]; do
if [ -f "$RESPONSE_FILE" ]; then
if command -v jq >/dev/null 2>&1; then
DECISION=$(jq -r '.decision // "deny"' "$RESPONSE_FILE")
Expand Down Expand Up @@ -125,7 +136,6 @@ while [ "$elapsed" -lt "$TIMEOUT_SEC" ]; do
fi
# shellcheck disable=SC2039
sleep "$SLEEP_SEC" 2>/dev/null || sleep 1
elapsed=$((elapsed + 1))
done

rm -f "$REQUEST_FILE" 2>/dev/null || true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -347,21 +347,23 @@ class AndCodeApplication : Application() {
notifications.notifyPermission(request, title, runtimeId)
},
onPermissionResolved = notifications::cancelPermission,
onSessionIdle = { sessionId, title ->
onSessionIdle = { sessionId, title, runtimeId ->
AnalyticsReporter.recordRuntimeSessionCompleted()
notifications.notifySessionComplete(sessionId, title)
notifications.notifySessionComplete(sessionId, title, runtimeId)
githubStarCoordinator.onSessionCompleted()
},
onSessionError = { sessionId, message ->
onSessionError = { sessionId, message, runtimeId ->
AnalyticsReporter.recordRuntimeSessionError()
CrashReporter.recordException(
error = IllegalStateException(SecretRedaction.redact(message ?: "Runtime session failed")),
message = "Runtime session error",
customKeys = mapOf("session_id" to (sessionId ?: "unknown")),
)
notifications.notifySessionError(sessionId, message)
notifications.notifySessionError(sessionId, message, runtimeId)
},
onQuestionAsked = { request, title, runtimeId ->
notifications.notifyQuestion(request, title, runtimeId)
},
onQuestionAsked = notifications::notifyQuestion,
unreadStore = settings,
messages = AndroidRuntimeActivityMessages(this),
)
Expand Down
25 changes: 20 additions & 5 deletions app/src/main/java/com/yugahashimoto/andcode/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,18 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.yugahashimoto.andcode.core.ProjectLinks
import com.yugahashimoto.andcode.core.locale.AppLanguage
import com.yugahashimoto.andcode.core.notification.RuntimeNotificationHelper
import com.yugahashimoto.andcode.feature.assistant.AndCodeVoiceInteractionService
import com.yugahashimoto.andcode.feature.assistant.AssistantStatus
import com.yugahashimoto.andcode.feature.support.GitHubStarPromptDialog
import com.yugahashimoto.andcode.feature.support.openProjectLink
import com.yugahashimoto.andcode.ui.AndCodeApp
import com.yugahashimoto.andcode.ui.ChatDeepLink
import java.util.UUID

class MainActivity : ComponentActivity() {
private var targetSessionId by mutableStateOf<String?>(null)
private var chatDeepLink by mutableStateOf<ChatDeepLink?>(null)
private var deepLinkToken = 0L
private var showInitialStarPrompt by mutableStateOf(false)
private var assistantActive by mutableStateOf(false)

Expand Down Expand Up @@ -73,7 +76,8 @@ class MainActivity : ComponentActivity() {
AndCodeApp(
onOpenAssistantSettings = ::openAssistantSettings,
assistantActive = assistantActive,
targetSessionId = targetSessionId,
chatDeepLink = chatDeepLink,
onChatDeepLinkConsumed = { chatDeepLink = null },
)
SnackbarHost(
hostState = snackbarHostState,
Expand Down Expand Up @@ -127,9 +131,20 @@ class MainActivity : ComponentActivity() {

private fun handleDeepLink(intent: Intent?) {
intent ?: return
intent.getStringExtra("target_session_id")?.let { id ->
targetSessionId = id
}
val sessionId =
intent.getStringExtra(RuntimeNotificationHelper.EXTRA_TARGET_SESSION_ID)
?.takeIf(String::isNotBlank) ?: return
deepLinkToken += 1
chatDeepLink =
ChatDeepLink(
sessionId = sessionId,
runtimeId = intent.getStringExtra(RuntimeNotificationHelper.EXTRA_RUNTIME_ID),
token = deepLinkToken,
)
// Consume the extras at once: the activity keeps this intent across configuration changes,
// and re-delivering it would yank the user back to a chat they have already left.
intent.removeExtra(RuntimeNotificationHelper.EXTRA_TARGET_SESSION_ID)
intent.removeExtra(RuntimeNotificationHelper.EXTRA_RUNTIME_ID)
Comment on lines +146 to +147

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · low]
removeExtra による「extras の消費」は、同一プロセス内の設定変更(recreation)時に同じ Intent インスタンスが再配送されるケースには有効ですが、プロセス死亡後の復元では、システム(ActivityRecord)が保持する元インテントのコピーが再配送されるため removeExtra の変更が反映されず、最後の deep link が再発火して「すでに離れたチャットへ引き戻される」問題が残ります。コメントの担保範囲を「設定変更のみ」に限定するか、onSaveInstanceState に消費済みセッションIDを保存して onCreate で再発火を抑止するガードを追加することを検討してください。

}

private fun openAssistantSettings() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,15 +81,17 @@ class RuntimeNotificationHelper(private val context: Context) {
fun notifyQuestion(
request: QuestionRequest,
chatTitle: String?,
runtimeId: String,
) {
if (!canPostNotifications()) return
val openIntent =
pendingActivityIntent(
requestCode = request.id.hashCode(),
requestCode = ("question:" + request.id).hashCode(),
extras =
mapOf(
EXTRA_OPEN_CHAT to true,
EXTRA_TARGET_SESSION_ID to request.sessionId,
EXTRA_RUNTIME_ID to runtimeId,
),
)
val prompt = request.questions.firstOrNull()?.question?.takeIf(String::isNotBlank)
Expand Down Expand Up @@ -123,15 +125,17 @@ class RuntimeNotificationHelper(private val context: Context) {
fun notifySessionComplete(
sessionId: String,
chatTitle: String?,
runtimeId: String,
) {
if (!canPostNotifications()) return
val intent =
pendingActivityIntent(
requestCode = sessionId.hashCode(),
requestCode = ("complete:" + sessionId).hashCode(),
extras =
mapOf(
EXTRA_OPEN_CHAT to true,
EXTRA_TARGET_SESSION_ID to sessionId,
EXTRA_RUNTIME_ID to runtimeId,
),
)
val notification =
Expand All @@ -153,15 +157,17 @@ class RuntimeNotificationHelper(private val context: Context) {
fun notifySessionError(
sessionId: String?,
message: String?,
runtimeId: String,
) {
if (!canPostNotifications()) return
val intent =
pendingActivityIntent(
requestCode = (sessionId ?: "error").hashCode(),
requestCode = ("error:" + (sessionId ?: "error")).hashCode(),
extras =
mapOf(
EXTRA_OPEN_ACTIVITY to true,
EXTRA_TARGET_SESSION_ID to (sessionId.orEmpty()),
EXTRA_RUNTIME_ID to runtimeId,
),
)
val notification =
Expand Down Expand Up @@ -266,19 +272,29 @@ class RuntimeNotificationHelper(private val context: Context) {
)
}

private fun permissionNotificationId(permissionId: String): Int = 20_000 + (permissionId.hashCode() and 0x0FFF)
// A narrow hash window made unrelated sessions collide onto one notification id, so one chat's
// notice visibly replaced another's and tapping it carried the wrong content intent. The wider
// mask makes that collision vanishingly unlikely.
private fun permissionNotificationId(permissionId: String): Int =
NOTIFICATION_ID_BASE_PERMISSION + (permissionId.hashCode() and NOTIFICATION_ID_MASK)

private fun questionNotificationId(questionId: String): Int = 25_000 + (questionId.hashCode() and 0x0FFF)
private fun questionNotificationId(questionId: String): Int =
NOTIFICATION_ID_BASE_QUESTION + (questionId.hashCode() and NOTIFICATION_ID_MASK)

private fun statusNotificationId(
sessionId: String,
kind: String,
): Int = 30_000 + ((sessionId + kind).hashCode() and 0x0FFF)
): Int = NOTIFICATION_ID_BASE_STATUS + ((sessionId + kind).hashCode() and NOTIFICATION_ID_MASK)

companion object {
const val CHANNEL_APPROVALS = "opencode_approvals"
const val CHANNEL_STATUS = "opencode_status"
const val ACTION_PERMISSION_RESPONSE = "com.yugahashimoto.andcode.PERMISSION_RESPONSE"

private const val NOTIFICATION_ID_MASK = 0x000FFFFF
private const val NOTIFICATION_ID_BASE_PERMISSION = 1_000_000
private const val NOTIFICATION_ID_BASE_QUESTION = 3_000_000
private const val NOTIFICATION_ID_BASE_STATUS = 5_000_000
const val EXTRA_OPEN_ACTIVITY = "open_activity"
const val EXTRA_OPEN_CHAT = "open_chat"
const val EXTRA_SESSION_ID = "session_id"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@ class RuntimeActivityRepository(
private val maxRetryDelayMillis: Long = 30_000L,
private val onPermissionAsked: ((PermissionRequest, String?, String) -> Unit)? = null,
private val onPermissionResolved: ((String) -> Unit)? = null,
private val onSessionIdle: ((String, String?) -> Unit)? = null,
private val onSessionError: ((String?, String?) -> Unit)? = null,
private val onQuestionAsked: ((QuestionRequest, String?) -> Unit)? = null,
private val onSessionIdle: ((String, String?, String) -> Unit)? = null,
private val onSessionError: ((String?, String?, String) -> Unit)? = null,
private val onQuestionAsked: ((QuestionRequest, String?, String) -> Unit)? = null,
private val unreadStore: UnreadSessionStore? = null,
private val messages: RuntimeActivityMessages = RuntimeActivityMessages,
) {
Expand Down Expand Up @@ -254,7 +254,7 @@ class RuntimeActivityRepository(
runCatching { target.session(event.sessionId).parentId != null }
.getOrDefault(false)
if (!isSubagent) {
onSessionIdle?.invoke(event.sessionId, sessionTitle(target, event.sessionId))
onSessionIdle?.invoke(event.sessionId, sessionTitle(target, event.sessionId), target.id)
}
}
is OpenCodeEvent.MessageUpdated -> {
Expand Down Expand Up @@ -309,7 +309,7 @@ class RuntimeActivityRepository(
}
}
appendLog(messages.eventError, event.message, event.sessionId)
onSessionError?.invoke(event.sessionId, event.message)
onSessionError?.invoke(event.sessionId, event.message, target.id)
}
is OpenCodeEvent.QuestionAsked -> {
mutableState.update { current ->
Expand All @@ -319,6 +319,7 @@ class RuntimeActivityRepository(
onQuestionAsked?.invoke(
event.request,
sessionTitle(target, event.request.sessionId),
target.id,
)
}
is OpenCodeEvent.Unknown -> appendLog(messages.eventUnknown, event.type)
Expand Down
12 changes: 9 additions & 3 deletions app/src/main/java/com/yugahashimoto/andcode/di/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -193,9 +193,15 @@ val appModule =
onPermissionAsked = { request, title, runtimeId ->
notifications.notifyPermission(request, title, runtimeId)
},
onSessionIdle = notifications::notifySessionComplete,
onSessionError = notifications::notifySessionError,
onQuestionAsked = notifications::notifyQuestion,
onSessionIdle = { sessionId, title, runtimeId ->
notifications.notifySessionComplete(sessionId, title, runtimeId)
},
Comment on lines +196 to +198

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[maintainability · low]
このラムダはパラメータをそのまま渡すだけのラッパーで、もともとのメソッド参照のままでもシグネチャが完全一致しているためそのまま書けます(onSessionIdle = notifications::notifySessionComplete 等)。ラムダ化しても機能差はなく冗長なので、可読性・簡潔性の観点からメソッド参照を維持する方が望ましいです。

Suggestion:

Suggested change
onSessionIdle = { sessionId, title, runtimeId ->
notifications.notifySessionComplete(sessionId, title, runtimeId)
},
onSessionIdle = notifications::notifySessionComplete,

onSessionError = { sessionId, message, runtimeId ->
notifications.notifySessionError(sessionId, message, runtimeId)
},
onQuestionAsked = { request, title, runtimeId ->
notifications.notifyQuestion(request, title, runtimeId)
},
messages = get<AndroidRuntimeActivityMessages>(),
)
}
Expand Down
Loading
Loading