Skip to content
Closed
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
1 change: 1 addition & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
SENTRY_DISABLE_AUTO_UPLOAD: ${{ github.event_name == 'pull_request' && 'true' || '' }}
steps:
- uses: actions/checkout@v6

Expand Down
4 changes: 4 additions & 0 deletions .maestro/flows/activation-positive.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ name: Activation - positive path (consent -> connect -> send message)

- tapOn:
id: "new-session-fab"
- assertVisible:
text: "New Session"
- tapOn:
id: "current-project-session-button"
- extendedWaitUntil:
visible:
id: "chat-message-input"
Expand Down
4 changes: 2 additions & 2 deletions .maestro/flows/directory-picker.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ name: Directory picker - browse server folders and create a session in one
timeout: 40000
- takeScreenshot: dirpicker-S1_connected

# Open the New Session modal (long-press the FAB for the full options sheet)
- longPressOn:
# Open the New Session modal
- tapOn:
id: "new-session-fab"
- assertVisible:
text: "New Session"
Expand Down
4 changes: 4 additions & 0 deletions .maestro/flows/variant-picker.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ name: Variant picker - reasoning-effort chip renders, selects, and still sends

- tapOn:
id: "new-session-fab"
- assertVisible:
text: "New Session"
- tapOn:
id: "current-project-session-button"
- extendedWaitUntil:
visible:
id: "chat-message-input"
Expand Down
20 changes: 20 additions & 0 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,26 @@ android {
def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true'
crunchPngs enablePngCrunchInRelease.toBoolean()
}
sidecarRelease {
initWith release
matchingFallbacks = ['release']
applicationIdSuffix ".test"
versionNameSuffix "-test"
resValue "string", "app_name", "OpenCode Test"
signingConfig signingConfigs.debug
}
}
applicationVariants.all { variant ->
if (variant.buildType.name == "sidecarRelease") {
variant.outputs.all { output ->
output.versionCodeOverride = (findProperty("testVersionCode") ?: defaultConfig.versionCode).toInteger()
}
}
}
tasks.register("assembleTestRelease") {
group = "build"
description = "Assembles the side-by-side test release APK."
dependsOn("assembleSidecarRelease")
}
packagingOptions {
jniLibs {
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 @@ -49,6 +49,7 @@
"plugins": [
"expo-router",
"./plugins/withKeyboardInsets",
"./plugins/withTestBuildType",
"expo-secure-store",
"expo-local-authentication",
[
Expand Down
9 changes: 1 addition & 8 deletions app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -425,12 +425,6 @@ export default function SessionsScreen() {
}, [])

const onFabPress = () => {
// Quick create in current project
onCreateSession()
}

const onFabLongPress = () => {
// Show modal with more options
setCustomDir("")
setShowNewSession(true)
}
Expand Down Expand Up @@ -587,8 +581,6 @@ export default function SessionsScreen() {
<TouchableOpacity
style={[styles.fab, isDark && styles.fabDark]}
onPress={onFabPress}
onLongPress={onFabLongPress}
delayLongPress={500}
testID="new-session-fab"
>
<Ionicons name="add" size={28} color={isDark ? "#0a0a0a" : "#ffffff"} />
Expand All @@ -615,6 +607,7 @@ export default function SessionsScreen() {
style={[styles.modalDirBox, isDark && styles.modalDirBoxDark]}
onPress={() => onCreateInDirectory()}
disabled={isCreating}
testID="current-project-session-button"
>
<Ionicons name="folder" size={20} color={isDark ? "#8b5cf6" : "#6d28d9"} />
<Text style={[styles.modalDirText, isDark && styles.textDark]} numberOfLines={2}>
Expand Down
50 changes: 46 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,50 @@ 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
// Keep the sessions tab underneath the restored screen so Back remains
// available after process recreation.
router.push({
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 +206,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 +267,7 @@ function RootLayout() {
}}
/>
</I18nextProvider>
</KeyboardProvider>
</ErrorBoundary>
)
}
Expand Down
6 changes: 6 additions & 0 deletions app/connection/add.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,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 @@ -211,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
Loading
Loading