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
4 changes: 2 additions & 2 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,10 @@ android {

namespace 'cc.agentlabs.opencode'
defaultConfig {
applicationId 'cc.agentlabs.opencode'
applicationId 'cc.agentlabs.opencode.test'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 42
versionCode 44
versionName "0.4.15"

buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
Expand Down
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
3 changes: 2 additions & 1 deletion android/app/src/main/res/values/colors.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@
<color name="splashscreen_background">#FFFFFF</color>
<color name="colorPrimary">#023c69</color>
<color name="colorPrimaryDark">#ffffff</color>
</resources>
<color name="iconBackground">#FFFFFF</color>
</resources>
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
48 changes: 44 additions & 4 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import { StatusBar } from "expo-status-bar"
import { useColorScheme, View, ActivityIndicator, AppState } from "react-native"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { GestureHandlerRootView } from "react-native-gesture-handler"
import { KeyboardProvider } from "react-native-keyboard-controller"
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"
import { I18nextProvider, useTranslation } from "react-i18next"
import i18n from "../src/lib/i18n/config"
import { useAuth } from "../src/stores/auth"
import { useConnections } from "../src/stores/connections"
import { useEvents } from "../src/stores/events"
import { useCatalog } from "../src/stores/catalog"
import { useSessions } from "../src/stores/sessions"
import { useSettings } from "../src/stores/settings"
import { AuthGate } from "../src/components/AuthGate"
import { ErrorBoundary } from "../src/components/ErrorBoundary"
Expand All @@ -31,6 +33,9 @@ function RootLayout() {
const { initialize: initAuth, isLoading: authLoading } = useAuth()
const { loadConnections, isLoading: connectionsLoading, client } = useConnections()
const sseStarted = useRef(false)
const wasBackgrounded = useRef(false)
const sessionRestoreAttempted = useRef(false)
const notificationOpened = useRef(false)
const notifPermissionRequested = useRef(false)

// Telemetry consent state: null = loading, 'unknown' = show modal, else decided
Expand All @@ -48,6 +53,7 @@ function RootLayout() {
// notifications carry no sessionId (they aren't about a session) — route
// to the home tab instead of "/session/" (an empty, dead-end route).
const unsubNotifications = notifications.onTap((data) => {
notificationOpened.current = true
if (data.sessionId) router.push(`/session/${data.sessionId}`)
else router.push("/")
})
Expand Down Expand Up @@ -78,19 +84,48 @@ function RootLayout() {
return unsubNotifications
}, [])

// Restore only the last selected session reference after Android recreates
// the process. The transcript remains server-owned and is fetched by the
// session screen; no assistant/tool output is persisted locally.
useEffect(() => {
if (!client || connectionsLoading || authLoading || consentState === "loading" || sessionRestoreAttempted.current || notificationOpened.current) return
sessionRestoreAttempted.current = true
void useSessions.getState().loadLastSession().then((last) => {
if (!last) return
const connections = useConnections.getState()
if (last.connectionID && connections.activeConnection?.id !== last.connectionID) return
router.replace({
pathname: "/session/[id]",
params: { id: last.sessionID, ...(last.directory ? { directory: last.directory } : {}) },
} as never)
})
}, [authLoading, client, connectionsLoading, consentState])

// Re-arm the biometric app-lock when the app leaves the foreground. Without
// this, "Require Biometric to Open" is bypassable: authenticate() sets
// isAuthenticated=true once at cold start and nothing ever resets it, so the
// app stays unlocked for the whole JS-process lifetime — anyone with brief
// physical access can reopen a backgrounded app straight into session
// history and connection details. lock() flips isAuthenticated back to false
// so AuthGate shows the lock screen (and re-prompts) on next foreground.
// Fire on "background" only (not the transient "inactive" that the biometric
// prompt / app switcher / control center produce) to avoid spurious re-locks.
// Preserve the in-progress composer draft and mark the SSE transport as no
// longer verified when the app really backgrounds. Don't tear the stream down
// here: OpenCode continues server-side regardless, and Android may keep the
// socket alive. On the next foreground, reconnectNow replaces a stale reader
// immediately rather than waiting on a JS timer that Doze may have frozen.
// Biometric locking remains restricted to the actual background transition.
useEffect(() => {
const sub = AppState.addEventListener("change", (next) => {
if (next === "background" && useAuth.getState().settings.requireBiometric) {
useAuth.getState().lock()
if (next === "background") {
wasBackgrounded.current = true
void useSessions.getState().flushDrafts().catch((err) => console.warn("[Sessions] Draft flush failed:", err))
useEvents.getState().background()
if (useAuth.getState().settings.requireBiometric) useAuth.getState().lock()
return
}
if (next === "active" && wasBackgrounded.current) {
wasBackgrounded.current = false
if (sseStarted.current) useEvents.getState().reconnectNow()
}
})
return () => sub.remove()
Expand Down Expand Up @@ -169,6 +204,10 @@ function RootLayout() {

return (
<ErrorBoundary>
{/* react-native-keyboard-controller: drives keyboard avoidance from
native WindowInsets instead of the legacy RN Keyboard events, which
misreport under Android's mandatory edge-to-edge mode (Expo SDK 54). */}
<KeyboardProvider>
<I18nextProvider i18n={i18n}>
<GestureHandlerRootView style={{ flex: 1 }}>
<BottomSheetModalProvider>
Expand Down Expand Up @@ -226,6 +265,7 @@ function RootLayout() {
}}
/>
</I18nextProvider>
</KeyboardProvider>
</ErrorBoundary>
)
}
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
24 changes: 22 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 @@ -133,6 +135,10 @@ export default function AddConnectionScreen() {
name: name.trim() || t("connection.shared.namePlaceholder"),
type: "local",
url: serverUrl,
// Carry the transport the pre-flight probe already determined, so the
// save does not probe the same server a second time.
api: result.api,
serverVersion: result.serverVersion,
},
password || undefined,
)
Expand Down Expand Up @@ -209,6 +215,8 @@ export default function AddConnectionScreen() {
url: url.trim(),
directory: directory.trim() || undefined,
username: username.trim() || undefined,
api: result.api,
serverVersion: result.serverVersion,
},
password || undefined,
)
Expand Down Expand Up @@ -315,8 +323,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 +529,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 +677,7 @@ export default function AddConnectionScreen() {
)}
</TouchableOpacity>
</ScrollView>
</KeyboardAvoidingView>
)
}

Expand Down
Loading