Skip to content
Open
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
14 changes: 14 additions & 0 deletions android/app/src/main/java/cc/agentlabs/opencode/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package cc.agentlabs.opencode

import android.os.Build
import android.os.Bundle
import android.view.View
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat

import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
Expand All @@ -17,6 +20,17 @@ class MainActivity : ReactActivity() {
// This is required for expo-splash-screen.
setTheme(R.style.AppTheme);
super.onCreate(null)

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
val root = findViewById<View>(android.R.id.content)
val bottom = root.paddingBottom
// Android 15+ no longer resizes edge-to-edge windows for the IME.
ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets ->
view.setPadding(view.paddingLeft, view.paddingTop, view.paddingRight, bottom + insets.getInsets(WindowInsetsCompat.Type.ime()).bottom)
insets
}
ViewCompat.requestApplyInsets(root)
}
}

/**
Expand Down
1 change: 1 addition & 0 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
},
"plugins": [
"expo-router",
"./plugins/withKeyboardInsets",
"expo-secure-store",
"expo-local-authentication",
[
Expand Down
10 changes: 9 additions & 1 deletion app/connection/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
TextInput,
TouchableOpacity,
ScrollView,
KeyboardAvoidingView,
Platform,
StyleSheet,
useColorScheme,
ActivityIndicator,
Expand Down Expand Up @@ -181,8 +183,13 @@ export default function EditConnectionScreen() {
}

return (
<ScrollView
<KeyboardAvoidingView
style={[styles.container, isDark && styles.containerDark]}
behavior="padding"
keyboardVerticalOffset={Platform.OS === "ios" ? 90 : 0}
>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={styles.content}
keyboardShouldPersistTaps="handled"
>
Expand Down Expand Up @@ -316,6 +323,7 @@ export default function EditConnectionScreen() {
</TouchableOpacity>
</View>
</ScrollView>
</KeyboardAvoidingView>
)
}

Expand Down
18 changes: 16 additions & 2 deletions app/connection/add.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
TextInput,
TouchableOpacity,
ScrollView,
KeyboardAvoidingView,
Platform,
StyleSheet,
useColorScheme,
ActivityIndicator,
Expand Down Expand Up @@ -315,8 +317,13 @@ export default function AddConnectionScreen() {
// Quick connect mode - simplified
if (mode === "quick") {
return (
<ScrollView
<KeyboardAvoidingView
style={[styles.container, isDark && styles.containerDark]}
behavior="padding"
keyboardVerticalOffset={Platform.OS === "ios" ? 90 : 0}
>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={styles.content}
keyboardShouldPersistTaps="handled"
>
Expand Down Expand Up @@ -516,13 +523,19 @@ export default function AddConnectionScreen() {
<Ionicons name="chevron-forward" size={16} color={isDark ? "#888888" : "#666666"} />
</TouchableOpacity>
</ScrollView>
</KeyboardAvoidingView>
)
}

// Advanced mode - full options
return (
<ScrollView
<KeyboardAvoidingView
style={[styles.container, isDark && styles.containerDark]}
behavior="padding"
keyboardVerticalOffset={Platform.OS === "ios" ? 90 : 0}
>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={styles.content}
keyboardShouldPersistTaps="handled"
>
Expand Down Expand Up @@ -658,6 +671,7 @@ export default function AddConnectionScreen() {
)}
</TouchableOpacity>
</ScrollView>
</KeyboardAvoidingView>
)
}

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"android": "expo run:android",
"typecheck": "tsc --noEmit",
"check:versions": "node scripts/check-version-parity.mjs",
"test": "node --test 'src/**/*.test.ts' 'scripts/*.test.mjs'"
"test": "node --test 'src/**/*.test.ts' 'scripts/*.test.mjs' 'plugins/*.test.js'"
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
Expand Down
38 changes: 38 additions & 0 deletions plugins/withKeyboardInsets.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
const { withMainActivity } = require("@expo/config-plugins")

const imports = `import android.view.View
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
`

const listener = `
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
val root = findViewById<View>(android.R.id.content)
val bottom = root.paddingBottom
// Android 15+ no longer resizes edge-to-edge windows for the IME.
ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets ->
view.setPadding(view.paddingLeft, view.paddingTop, view.paddingRight, bottom + insets.getInsets(WindowInsetsCompat.Type.ime()).bottom)
insets
}
ViewCompat.requestApplyInsets(root)
}
`

function addKeyboardInsets(contents) {
if (contents.includes("WindowInsetsCompat.Type.ime()")) return contents
if (!contents.includes("import android.os.Bundle\n") || !contents.includes(" super.onCreate(null)\n")) {
throw new Error("Unsupported MainActivity.kt template")
}
return contents
.replace("import android.os.Bundle\n", `import android.os.Bundle\n${imports}`)
.replace(" super.onCreate(null)\n", ` super.onCreate(null)\n${listener}`)
}

module.exports = (config) =>
withMainActivity(config, (config) => {
if (config.modResults.language !== "kt") throw new Error("Keyboard inset fix requires MainActivity.kt")
config.modResults.contents = addKeyboardInsets(config.modResults.contents)
return config
})

module.exports.addKeyboardInsets = addKeyboardInsets
10 changes: 10 additions & 0 deletions plugins/withKeyboardInsets.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
const assert = require("node:assert/strict")
const test = require("node:test")
const { addKeyboardInsets } = require("./withKeyboardInsets")

test("adds Android IME insets listener once", () => {
const source = "import android.os.Bundle\nclass MainActivity {\n super.onCreate(null)\n}\n"
const result = addKeyboardInsets(source)
assert.match(result, /WindowInsetsCompat\.Type\.ime\(\)/)
assert.equal(addKeyboardInsets(result), result)
})
27 changes: 27 additions & 0 deletions src/lib/permissions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import assert from "node:assert/strict"
import test from "node:test"
import { mergePendingPermissions, normalizePermissionEvent } from "./permissions.ts"

test("normalizes legacy permission asks", () => {
assert.deepEqual(normalizePermissionEvent({ type: "permission.asked", properties: {
id: "p1", sessionID: "s1", permission: "bash", patterns: ["printf hi"], metadata: {},
} }), { id: "p1", sessionID: "s1", permission: "bash", patterns: ["printf hi"], metadata: {} })
})

test("normalizes PermissionV2 action/resources/metadata/source", () => {
assert.deepEqual(normalizePermissionEvent({ type: "permission.v2.asked", properties: {
id: "p2", sessionID: "s1", action: "bash", resources: ["printf hi"], metadata: { command: "printf hi" }, source: { tool: "bash" },
} }), { id: "p2", sessionID: "s1", permission: "bash", patterns: ["printf hi"], action: "bash", resources: ["printf hi"], metadata: { command: "printf hi" }, source: { tool: "bash" } })
})

test("rejects malformed requests and ignores unrelated events", () => {
assert.equal(normalizePermissionEvent({ type: "permission.v2.asked", properties: { id: "p", sessionID: "s" } }), null)
assert.equal(normalizePermissionEvent({ type: "other", properties: {} }), null)
})

test("deduplicates replayed IDs and lets the latest request data win", () => {
const item = normalizePermissionEvent({ type: "permission.v2.asked", properties: {
id: "p", sessionID: "s", action: "bash", resources: ["safe"],
} })!
assert.deepEqual(mergePendingPermissions([item], [item]), [item])
})
74 changes: 74 additions & 0 deletions src/lib/permissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
export type PendingPermission = {
id: string
sessionID: string
permission: string
patterns: string[]
metadata: Record<string, unknown>
tool?: { messageID: string; callID: string }
action?: string
resources?: string[]
save?: string[]
source?: Record<string, unknown>
}

type PermissionEvent = {
type?: string
properties?: Record<string, unknown>
}

/** Normalize both generations of server permission request into the prompt model. */
export function normalizePermissionEvent(event: PermissionEvent): PendingPermission | null {
if (event.type !== "permission.asked" && event.type !== "permission.v2.asked") return null
const properties = event.properties
if (!properties || typeof properties.id !== "string" || !properties.id ||
typeof properties.sessionID !== "string" || !properties.sessionID) return null

if (event.type === "permission.asked") {
if (typeof properties.permission !== "string" || !Array.isArray(properties.patterns) ||
!properties.patterns.every((item) => typeof item === "string")) return null
return {
id: properties.id,
sessionID: properties.sessionID,
permission: properties.permission,
patterns: properties.patterns,
metadata: isRecord(properties.metadata) ? properties.metadata : {},
...(isRecord(properties.tool) ? { tool: properties.tool as PendingPermission["tool"] } : {}),
}
}

if (typeof properties.action !== "string" || !Array.isArray(properties.resources) ||
!properties.resources.every((item) => typeof item === "string")) return null
return {
id: properties.id,
sessionID: properties.sessionID,
permission: properties.action,
patterns: properties.resources,
metadata: isRecord(properties.metadata) ? properties.metadata : {},
action: properties.action,
resources: properties.resources,
...(Array.isArray(properties.save) && properties.save.every((item) => typeof item === "string")
? { save: properties.save as string[] } : {}),
...(isRecord(properties.source) ? { source: properties.source } : {}),
}
}

/** Convert a pending-list API item (which may be from either protocol generation). */
export function normalizePendingPermission(value: unknown): PendingPermission | null {
if (!isRecord(value)) return null
return normalizePermissionEvent({
type: Array.isArray(value.resources) || typeof value.action === "string" ? "permission.v2.asked" : "permission.asked",
properties: value,
})
}

export function mergePendingPermissions(
current: PendingPermission[], incoming: PendingPermission[],
): PendingPermission[] {
const merged = new Map(current.map((item) => [item.id, item]))
for (const item of incoming) merged.set(item.id, { ...merged.get(item.id), ...item })
return [...merged.values()]
}

function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value)
}
Loading