Extract Android session creation into standalone activity

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Iliyan Malchev
2026-03-23 10:04:55 -07:00
parent 040b55c91b
commit 2ea5cb7139
8 changed files with 651 additions and 538 deletions

View File

@@ -36,6 +36,15 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".CreateSessionActivity"
android:exported="true"
android:excludeFromRecents="true"
android:launchMode="singleTop"
android:taskAffinity="com.openai.codex.agent.create"
android:theme="@style/CodexCreateSessionTheme">
<intent-filter>
<action android:name="android.app.agent.action.HANDLE_SESSION" />
<category android:name="android.intent.category.DEFAULT" />

View File

@@ -0,0 +1,470 @@
package com.openai.codex.agent
import android.app.Activity
import android.app.agent.AgentManager
import android.app.agent.AgentSessionInfo
import android.content.Context
import android.content.Intent
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.Button
import android.widget.EditText
import android.widget.ImageView
import android.widget.Spinner
import android.widget.TextView
import android.widget.Toast
import com.openai.codex.bridge.SessionExecutionSettings
import kotlin.concurrent.thread
class CreateSessionActivity : Activity() {
companion object {
private const val TAG = "CodexCreateSession"
private const val EXTRA_EXISTING_SESSION_ID = "existingSessionId"
private const val EXTRA_TARGET_PACKAGE = "targetPackage"
private const val EXTRA_LOCK_TARGET = "lockTarget"
private const val EXTRA_INITIAL_MODEL = "initialModel"
private const val EXTRA_INITIAL_REASONING_EFFORT = "initialReasoningEffort"
fun newSessionIntent(
context: Context,
initialSettings: SessionExecutionSettings,
): Intent {
return Intent(context, CreateSessionActivity::class.java).apply {
putExtra(EXTRA_INITIAL_MODEL, initialSettings.model)
putExtra(EXTRA_INITIAL_REASONING_EFFORT, initialSettings.reasoningEffort)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}
fun existingHomeSessionIntent(
context: Context,
sessionId: String,
targetPackage: String,
initialSettings: SessionExecutionSettings,
): Intent {
return newSessionIntent(context, initialSettings).apply {
putExtra(EXTRA_EXISTING_SESSION_ID, sessionId)
putExtra(EXTRA_TARGET_PACKAGE, targetPackage)
putExtra(EXTRA_LOCK_TARGET, true)
}
}
}
private val sessionController by lazy { AgentSessionController(this) }
private var availableModels: List<AgentModelOption> = emptyList()
@Volatile
private var modelsRefreshInFlight = false
private val pendingModelCallbacks = mutableListOf<() -> Unit>()
private var existingSessionId: String? = null
private var selectedPackage: InstalledApp? = null
private var targetLocked = false
private lateinit var promptInput: EditText
private lateinit var packageSummary: TextView
private lateinit var packageButton: Button
private lateinit var clearPackageButton: Button
private lateinit var modelSpinner: Spinner
private lateinit var effortSpinner: Spinner
private lateinit var titleView: TextView
private lateinit var statusView: TextView
private lateinit var startButton: Button
private var selectedReasoningOptions = emptyList<AgentReasoningEffortOption>()
private lateinit var effortLabelAdapter: ArrayAdapter<String>
private var initialSettings = SessionExecutionSettings.default
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_create_session)
setFinishOnTouchOutside(true)
bindViews()
loadInitialState()
refreshModelsIfNeeded(force = true)
}
private fun bindViews() {
titleView = findViewById(R.id.create_session_title)
statusView = findViewById(R.id.create_session_status)
promptInput = findViewById(R.id.create_session_prompt)
packageSummary = findViewById(R.id.create_session_target_summary)
packageButton = findViewById(R.id.create_session_pick_target_button)
clearPackageButton = findViewById(R.id.create_session_clear_target_button)
modelSpinner = findViewById(R.id.create_session_model_spinner)
effortSpinner = findViewById(R.id.create_session_effort_spinner)
startButton = findViewById(R.id.create_session_start_button)
effortLabelAdapter = ArrayAdapter(
this,
android.R.layout.simple_spinner_item,
mutableListOf<String>(),
).also {
it.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
effortSpinner.adapter = it
}
modelSpinner.adapter = ArrayAdapter(
this,
android.R.layout.simple_spinner_item,
mutableListOf<String>(),
).also { it.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) }
modelSpinner.onItemSelectedListener = SimpleItemSelectedListener { updateEffortOptions(null) }
packageButton.setOnClickListener {
showInstalledAppPicker { app ->
selectedPackage = app
updatePackageSummary()
}
}
clearPackageButton.setOnClickListener {
selectedPackage = null
updatePackageSummary()
}
findViewById<Button>(R.id.create_session_cancel_button).setOnClickListener {
finish()
}
startButton.setOnClickListener {
startSession()
}
updatePackageSummary()
}
private fun loadInitialState() {
existingSessionId = intent.getStringExtra(EXTRA_EXISTING_SESSION_ID)?.trim()?.ifEmpty { null }
initialSettings = SessionExecutionSettings(
model = intent.getStringExtra(EXTRA_INITIAL_MODEL)?.trim()?.ifEmpty { null },
reasoningEffort = intent.getStringExtra(EXTRA_INITIAL_REASONING_EFFORT)?.trim()?.ifEmpty { null },
)
promptInput.setText("")
val explicitTarget = intent.getStringExtra(EXTRA_TARGET_PACKAGE)?.trim()?.ifEmpty { null }
targetLocked = intent.getBooleanExtra(EXTRA_LOCK_TARGET, false)
if (explicitTarget != null) {
selectedPackage = InstalledAppCatalog.resolveInstalledApp(this, sessionController, explicitTarget)
titleView.text = "New Session"
updatePackageSummary()
if (targetLocked) {
lockTargetSelection()
}
return
}
val incomingSessionId = intent.getStringExtra(AgentManager.EXTRA_SESSION_ID)?.trim()?.ifEmpty { null }
if (incomingSessionId != null) {
statusView.visibility = View.VISIBLE
statusView.text = "Loading session…"
startButton.isEnabled = false
thread {
val draftSession = runCatching {
findStandaloneHomeDraftSession(incomingSessionId)
}.getOrElse { err ->
Log.w(TAG, "Failed to inspect incoming session $incomingSessionId", err)
null
}
runOnUiThread {
if (draftSession == null) {
startActivity(
Intent(this, SessionDetailActivity::class.java)
.putExtra(SessionDetailActivity.EXTRA_SESSION_ID, incomingSessionId),
)
finish()
return@runOnUiThread
}
existingSessionId = draftSession.sessionId
selectedPackage = InstalledAppCatalog.resolveInstalledApp(
this,
sessionController,
checkNotNull(draftSession.targetPackage),
)
initialSettings = sessionController.executionSettingsForSession(draftSession.sessionId)
targetLocked = true
titleView.text = "New Session"
updatePackageSummary()
lockTargetSelection()
statusView.visibility = View.GONE
startButton.isEnabled = true
if (availableModels.isNotEmpty()) {
applyModelOptions()
}
}
}
}
}
private fun lockTargetSelection() {
packageButton.visibility = View.GONE
clearPackageButton.visibility = View.GONE
}
private fun startSession() {
val prompt = promptInput.text.toString().trim()
if (prompt.isEmpty()) {
promptInput.error = "Enter a prompt"
return
}
val targetPackage = selectedPackage?.packageName
if (existingSessionId != null && targetPackage == null) {
showToast("Missing target app for existing session")
return
}
startButton.isEnabled = false
thread {
runCatching {
AgentSessionLauncher.startSession(
context = this,
request = LaunchSessionRequest(
prompt = prompt,
targetPackage = targetPackage,
model = selectedModel().model,
reasoningEffort = selectedEffort(),
existingSessionId = existingSessionId,
),
sessionController = sessionController,
requestUserInputHandler = { questions ->
AgentUserInputPrompter.promptForAnswers(this, questions)
},
)
}.onFailure { err ->
runOnUiThread {
startButton.isEnabled = true
showToast("Failed to start session: ${err.message}")
}
}.onSuccess { result ->
showToast("Started session")
setResult(RESULT_OK, Intent().putExtra(SessionDetailActivity.EXTRA_SESSION_ID, result.parentSessionId))
finish()
}
}
}
private fun refreshModelsIfNeeded(
force: Boolean,
onComplete: (() -> Unit)? = null,
) {
if (!force && availableModels.isNotEmpty()) {
onComplete?.invoke()
return
}
if (onComplete != null) {
synchronized(pendingModelCallbacks) {
pendingModelCallbacks += onComplete
}
}
if (modelsRefreshInFlight) {
return
}
modelsRefreshInFlight = true
thread {
try {
runCatching { AgentCodexAppServerClient.listModels(this) }
.onFailure { err ->
Log.w(TAG, "Failed to load model catalog", err)
}
.onSuccess { models ->
availableModels = models
}
} finally {
runOnUiThread {
if (availableModels.isNotEmpty()) {
applyModelOptions()
} else {
statusView.visibility = View.VISIBLE
statusView.text = "Failed to load model catalog."
}
}
modelsRefreshInFlight = false
val callbacks = synchronized(pendingModelCallbacks) {
pendingModelCallbacks.toList().also { pendingModelCallbacks.clear() }
}
callbacks.forEach { callback -> callback.invoke() }
}
}
}
private fun applyModelOptions() {
val models = availableModels.ifEmpty(::fallbackModels)
if (availableModels.isEmpty()) {
availableModels = models
}
val labels = models.map { model ->
if (model.description.isBlank()) {
model.displayName
} else {
"${model.displayName} (${model.description})"
}
}
val adapter = ArrayAdapter(
this,
android.R.layout.simple_spinner_item,
labels,
)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
modelSpinner.adapter = adapter
val modelIndex = models.indexOfFirst { it.model == initialSettings.model }
.takeIf { it >= 0 } ?: models.indexOfFirst(AgentModelOption::isDefault)
.takeIf { it >= 0 } ?: 0
modelSpinner.setSelection(modelIndex, false)
updateEffortOptions(initialSettings.reasoningEffort)
statusView.visibility = View.GONE
}
private fun selectedModel(): AgentModelOption {
return availableModels[modelSpinner.selectedItemPosition.coerceIn(0, availableModels.lastIndex)]
}
private fun selectedEffort(): String? {
return selectedReasoningOptions.getOrNull(effortSpinner.selectedItemPosition)?.reasoningEffort
}
private fun updateEffortOptions(requestedEffort: String?) {
if (availableModels.isEmpty()) {
return
}
selectedReasoningOptions = selectedModel().supportedReasoningEfforts
val labels = selectedReasoningOptions.map { option ->
"${option.reasoningEffort}${option.description}"
}
effortLabelAdapter.clear()
effortLabelAdapter.addAll(labels)
effortLabelAdapter.notifyDataSetChanged()
val desiredEffort = requestedEffort ?: selectedModel().defaultReasoningEffort
val selectedIndex = selectedReasoningOptions.indexOfFirst { it.reasoningEffort == desiredEffort }
.takeIf { it >= 0 } ?: 0
effortSpinner.setSelection(selectedIndex, false)
}
private fun updatePackageSummary() {
val app = selectedPackage
if (app == null) {
packageSummary.text = "No target app selected. This will start an Agent-anchored session."
packageSummary.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, null, null)
return
}
packageSummary.text = "${app.label} (${app.packageName})"
packageSummary.setCompoundDrawablesRelativeWithIntrinsicBounds(
resizeIcon(app.icon),
null,
null,
null,
)
packageSummary.compoundDrawablePadding =
resources.getDimensionPixelSize(android.R.dimen.app_icon_size) / 4
}
private fun showInstalledAppPicker(onSelected: (InstalledApp) -> Unit) {
val apps = InstalledAppCatalog.listInstalledApps(this, sessionController)
if (apps.isEmpty()) {
android.app.AlertDialog.Builder(this)
.setMessage("No launchable target apps are available.")
.setPositiveButton(android.R.string.ok, null)
.show()
return
}
val adapter = object : ArrayAdapter<InstalledApp>(
this,
R.layout.list_item_installed_app,
apps,
) {
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
return bindAppRow(position, convertView, parent)
}
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
return bindAppRow(position, convertView, parent)
}
private fun bindAppRow(position: Int, convertView: View?, parent: ViewGroup): View {
val row = convertView ?: LayoutInflater.from(context)
.inflate(R.layout.list_item_installed_app, parent, false)
val app = getItem(position) ?: return row
val iconView = row.findViewById<ImageView>(R.id.installed_app_icon)
val titleView = row.findViewById<TextView>(R.id.installed_app_title)
val subtitleView = row.findViewById<TextView>(R.id.installed_app_subtitle)
iconView.setImageDrawable(app.icon ?: getDrawable(android.R.drawable.sym_def_app_icon))
titleView.text = app.label
subtitleView.text = if (app.eligibleTarget) {
app.packageName
} else {
"${app.packageName} — unavailable"
}
row.isEnabled = app.eligibleTarget
titleView.isEnabled = app.eligibleTarget
subtitleView.isEnabled = app.eligibleTarget
iconView.alpha = if (app.eligibleTarget) 1f else 0.5f
row.alpha = if (app.eligibleTarget) 1f else 0.6f
return row
}
}
val dialog = android.app.AlertDialog.Builder(this)
.setTitle("Choose app")
.setAdapter(adapter) { _, which ->
val app = apps[which]
if (!app.eligibleTarget) {
android.app.AlertDialog.Builder(this)
.setMessage(
"The current framework rejected ${app.packageName} as a target for Genie sessions on this device.",
)
.setPositiveButton(android.R.string.ok, null)
.show()
return@setAdapter
}
onSelected(app)
}
.setNegativeButton(android.R.string.cancel, null)
.create()
dialog.setOnShowListener {
dialog.listView?.isVerticalScrollBarEnabled = true
dialog.listView?.isScrollbarFadingEnabled = false
dialog.listView?.isFastScrollEnabled = true
dialog.listView?.scrollBarStyle = View.SCROLLBARS_INSIDE_INSET
}
dialog.show()
}
private fun findStandaloneHomeDraftSession(sessionId: String): AgentSessionDetails? {
val snapshot = sessionController.loadSnapshot(sessionId)
val session = snapshot.sessions.firstOrNull { it.sessionId == sessionId } ?: return null
val hasChildren = snapshot.sessions.any { it.parentSessionId == sessionId }
return session.takeIf {
it.anchor == AgentSessionInfo.ANCHOR_HOME &&
it.state == AgentSessionInfo.STATE_CREATED &&
!hasChildren &&
!it.targetPackage.isNullOrBlank()
}
}
private fun resizeIcon(icon: Drawable?): Drawable? {
val sizedIcon = icon?.constantState?.newDrawable()?.mutate() ?: return null
val iconSize = resources.getDimensionPixelSize(android.R.dimen.app_icon_size)
sizedIcon.setBounds(0, 0, iconSize, iconSize)
return sizedIcon
}
private fun fallbackModels(): List<AgentModelOption> {
return listOf(
AgentModelOption(
id = initialSettings.model ?: "default",
model = initialSettings.model ?: "gpt-5.3-codex",
displayName = initialSettings.model ?: "Default model",
description = "Current Agent runtime default",
supportedReasoningEfforts = listOf(
AgentReasoningEffortOption("minimal", "Fastest"),
AgentReasoningEffortOption("low", "Low"),
AgentReasoningEffortOption("medium", "Balanced"),
AgentReasoningEffortOption("high", "Deep"),
AgentReasoningEffortOption("xhigh", "Max"),
),
defaultReasoningEffort = initialSettings.reasoningEffort ?: "medium",
isDefault = true,
),
)
}
private fun showToast(message: String) {
runOnUiThread {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
}
}

View File

@@ -1,277 +0,0 @@
package com.openai.codex.agent
import android.app.Activity
import android.app.AlertDialog
import android.graphics.drawable.Drawable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.Button
import android.widget.EditText
import android.widget.ImageView
import android.widget.Spinner
import android.widget.TextView
import com.openai.codex.bridge.SessionExecutionSettings
class CreateSessionDialogController(
private val activity: Activity,
private val sessionController: AgentSessionController,
) {
data class InitialTargetSelection(
val packageName: String,
val locked: Boolean,
)
fun show(
models: List<AgentModelOption>,
initialPrompt: String,
initialSettings: SessionExecutionSettings,
initialTargetSelection: InitialTargetSelection? = null,
existingSessionId: String? = null,
onSubmit: (LaunchSessionRequest) -> Unit,
) {
val dialogView = LayoutInflater.from(activity)
.inflate(R.layout.dialog_create_session, null)
val promptInput = dialogView.findViewById<EditText>(R.id.create_session_prompt)
val packageSummary = dialogView.findViewById<TextView>(R.id.create_session_target_summary)
val packageButton = dialogView.findViewById<Button>(R.id.create_session_pick_target_button)
val clearPackageButton = dialogView.findViewById<Button>(R.id.create_session_clear_target_button)
val modelSpinner = dialogView.findViewById<Spinner>(R.id.create_session_model_spinner)
val effortSpinner = dialogView.findViewById<Spinner>(R.id.create_session_effort_spinner)
promptInput.setText(initialPrompt)
val availableModels = models.ifEmpty {
listOf(
AgentModelOption(
id = initialSettings.model ?: "default",
model = initialSettings.model ?: "gpt-5.3-codex",
displayName = initialSettings.model ?: "Default model",
description = "Current Agent runtime default",
supportedReasoningEfforts = listOf(
AgentReasoningEffortOption("minimal", "Fastest"),
AgentReasoningEffortOption("low", "Low"),
AgentReasoningEffortOption("medium", "Balanced"),
AgentReasoningEffortOption("high", "Deep"),
AgentReasoningEffortOption("xhigh", "Max"),
),
defaultReasoningEffort = initialSettings.reasoningEffort ?: "medium",
isDefault = true,
),
)
}
val modelLabels = availableModels.map { model ->
if (model.description.isBlank()) {
model.displayName
} else {
"${model.displayName} (${model.description})"
}
}
modelSpinner.adapter = ArrayAdapter(
activity,
android.R.layout.simple_spinner_item,
modelLabels,
).also { it.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) }
var selectedPackage: InstalledApp? = initialTargetSelection?.let { selection ->
resolveInstalledApp(selection.packageName)
}
var selectedReasoningOptions = emptyList<AgentReasoningEffortOption>()
val effortLabelAdapter = ArrayAdapter(
activity,
android.R.layout.simple_spinner_item,
mutableListOf<String>(),
).also {
it.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
effortSpinner.adapter = it
}
fun selectedModel(): AgentModelOption {
return availableModels[modelSpinner.selectedItemPosition.coerceIn(0, availableModels.lastIndex)]
}
fun selectedEffort(): String? {
val selectedIndex = effortSpinner.selectedItemPosition
return selectedReasoningOptions.getOrNull(selectedIndex)?.reasoningEffort
}
fun updatePackageSummary() {
val app = selectedPackage
if (app == null) {
packageSummary.text = "No target app selected. This will start an Agent-anchored session."
packageSummary.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, null, null)
return
}
packageSummary.text = "${app.label} (${app.packageName})"
packageSummary.setCompoundDrawablesRelativeWithIntrinsicBounds(
resizeIcon(app.icon),
null,
null,
null,
)
packageSummary.compoundDrawablePadding =
activity.resources.getDimensionPixelSize(android.R.dimen.app_icon_size) / 4
}
fun updateEffortOptions(requestedEffort: String?) {
selectedReasoningOptions = selectedModel().supportedReasoningEfforts
val labels = selectedReasoningOptions.map { option ->
"${option.reasoningEffort}${option.description}"
}
effortLabelAdapter.clear()
effortLabelAdapter.addAll(labels)
effortLabelAdapter.notifyDataSetChanged()
val desiredEffort = requestedEffort
?: selectedModel().defaultReasoningEffort
val selectedIndex = selectedReasoningOptions.indexOfFirst { option ->
option.reasoningEffort == desiredEffort
}.takeIf { it >= 0 } ?: 0
effortSpinner.setSelection(selectedIndex, false)
}
val modelIndex = availableModels.indexOfFirst { model ->
model.model == initialSettings.model
}.takeIf { it >= 0 } ?: availableModels.indexOfFirst(AgentModelOption::isDefault)
.takeIf { it >= 0 } ?: 0
modelSpinner.setSelection(modelIndex, false)
updateEffortOptions(initialSettings.reasoningEffort)
modelSpinner.setOnItemSelectedListener(
SimpleItemSelectedListener { updateEffortOptions(null) },
)
packageButton.setOnClickListener {
showInstalledAppPicker { app ->
selectedPackage = app
updatePackageSummary()
}
}
clearPackageButton.setOnClickListener {
selectedPackage = null
updatePackageSummary()
}
if (initialTargetSelection?.locked == true) {
packageButton.isEnabled = false
clearPackageButton.isEnabled = false
packageButton.visibility = View.GONE
clearPackageButton.visibility = View.GONE
}
updatePackageSummary()
val dialog = AlertDialog.Builder(activity)
.setTitle("New Session")
.setView(dialogView)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton("Start", null)
.create()
dialog.setOnShowListener {
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
val prompt = promptInput.text.toString().trim()
if (prompt.isEmpty()) {
promptInput.error = "Enter a prompt"
return@setOnClickListener
}
onSubmit(
LaunchSessionRequest(
prompt = prompt,
targetPackage = selectedPackage?.packageName,
model = selectedModel().model,
reasoningEffort = selectedEffort(),
existingSessionId = existingSessionId,
),
)
dialog.dismiss()
}
}
dialog.show()
}
private fun resolveInstalledApp(packageName: String): InstalledApp {
val apps = InstalledAppCatalog.listInstalledApps(activity, sessionController)
apps.firstOrNull { it.packageName == packageName }?.let { return it }
val pm = activity.packageManager
val applicationInfo = pm.getApplicationInfo(packageName, 0)
return InstalledApp(
packageName = packageName,
label = pm.getApplicationLabel(applicationInfo)?.toString().orEmpty().ifBlank { packageName },
icon = pm.getApplicationIcon(applicationInfo),
eligibleTarget = sessionController.canStartSessionForTarget(packageName),
)
}
private fun showInstalledAppPicker(onSelected: (InstalledApp) -> Unit) {
val apps = InstalledAppCatalog.listInstalledApps(activity, sessionController)
if (apps.isEmpty()) {
AlertDialog.Builder(activity)
.setMessage("No launchable target apps are available.")
.setPositiveButton(android.R.string.ok, null)
.show()
return
}
val adapter = object : ArrayAdapter<InstalledApp>(
activity,
R.layout.list_item_installed_app,
apps,
) {
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
return bindAppRow(position, convertView, parent)
}
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
return bindAppRow(position, convertView, parent)
}
private fun bindAppRow(position: Int, convertView: View?, parent: ViewGroup): View {
val row = convertView ?: LayoutInflater.from(context)
.inflate(R.layout.list_item_installed_app, parent, false)
val app = getItem(position) ?: return row
val iconView = row.findViewById<ImageView>(R.id.installed_app_icon)
val titleView = row.findViewById<TextView>(R.id.installed_app_title)
val subtitleView = row.findViewById<TextView>(R.id.installed_app_subtitle)
iconView.setImageDrawable(app.icon ?: activity.getDrawable(android.R.drawable.sym_def_app_icon))
titleView.text = app.label
subtitleView.text = if (app.eligibleTarget) {
app.packageName
} else {
"${app.packageName} — unavailable"
}
row.isEnabled = app.eligibleTarget
titleView.isEnabled = app.eligibleTarget
subtitleView.isEnabled = app.eligibleTarget
iconView.alpha = if (app.eligibleTarget) 1f else 0.5f
row.alpha = if (app.eligibleTarget) 1f else 0.6f
return row
}
}
val dialog = AlertDialog.Builder(activity)
.setTitle("Choose app")
.setAdapter(adapter) { _, which ->
val app = apps[which]
if (!app.eligibleTarget) {
AlertDialog.Builder(activity)
.setMessage(
"The current framework rejected ${app.packageName} as a target for Genie sessions on this device.",
)
.setPositiveButton(android.R.string.ok, null)
.show()
return@setAdapter
}
onSelected(app)
}
.setNegativeButton(android.R.string.cancel, null)
.create()
dialog.setOnShowListener {
dialog.listView?.isVerticalScrollBarEnabled = true
dialog.listView?.isScrollbarFadingEnabled = false
dialog.listView?.isFastScrollEnabled = true
dialog.listView?.scrollBarStyle = View.SCROLLBARS_INSIDE_INSET
}
dialog.show()
}
private fun resizeIcon(icon: Drawable?): Drawable? {
val sizedIcon = icon?.constantState?.newDrawable()?.mutate() ?: return null
val iconSize = activity.resources.getDimensionPixelSize(android.R.dimen.app_icon_size)
sizedIcon.setBounds(0, 0, iconSize, iconSize)
return sizedIcon
}
}

View File

@@ -47,4 +47,22 @@ object InstalledAppCatalog {
compareBy<InstalledApp>({ it.label.lowercase() }).thenBy { it.packageName },
)
}
fun resolveInstalledApp(
context: Context,
sessionController: AgentSessionController,
packageName: String,
): InstalledApp {
listInstalledApps(context, sessionController)
.firstOrNull { it.packageName == packageName }
?.let { return it }
val pm = context.packageManager
val applicationInfo = pm.getApplicationInfo(packageName, 0)
return InstalledApp(
packageName = packageName,
label = pm.getApplicationLabel(applicationInfo)?.toString().orEmpty().ifBlank { packageName },
icon = pm.getApplicationIcon(applicationInfo),
eligibleTarget = sessionController.canStartSessionForTarget(packageName),
)
}
}

View File

@@ -38,17 +38,10 @@ class MainActivity : Activity() {
private var latestAgentRuntimeStatus: AgentCodexAppServerClient.RuntimeStatus? = null
@Volatile
private var pendingAuthMessage: String? = null
@Volatile
private var modelsRefreshInFlight = false
private val pendingModelCallbacks = mutableListOf<() -> Unit>()
private val agentSessionController by lazy { AgentSessionController(this) }
private val dismissedSessionStore by lazy { DismissedSessionStore(this) }
private val createSessionDialogController by lazy {
CreateSessionDialogController(this, agentSessionController)
}
private val sessionListAdapter by lazy { TopLevelSessionListAdapter(this) }
private var availableModels: List<AgentModelOption> = emptyList()
private var latestSnapshot: AgentSnapshot = AgentSnapshot.unavailable
private val runtimeStatusListener = AgentCodexAppServerClient.RuntimeStatusListener { status ->
@@ -94,7 +87,6 @@ class MainActivity : Activity() {
AgentCodexAppServerClient.registerRuntimeStatusListener(runtimeStatusListener)
AgentCodexAppServerClient.refreshRuntimeStatusAsync(this, refreshToken = true)
refreshAgentSessions(force = true)
refreshModelsIfNeeded(force = availableModels.isEmpty())
}
override fun onPause() {
@@ -111,14 +103,13 @@ class MainActivity : Activity() {
}
}
findViewById<Button>(R.id.create_session_button).setOnClickListener {
showCreateSessionDialog()
launchCreateSessionActivity()
}
findViewById<Button>(R.id.auth_action).setOnClickListener {
authAction()
}
findViewById<Button>(R.id.refresh_sessions_button).setOnClickListener {
refreshAgentSessions(force = true)
refreshModelsIfNeeded(force = true)
}
updateAuthUi("Agent auth: probing...", false)
updateRuntimeStatusUi()
@@ -128,37 +119,12 @@ class MainActivity : Activity() {
private fun handleIncomingIntent(intent: Intent?) {
val sessionId = intent?.getStringExtra(AgentManager.EXTRA_SESSION_ID)
if (!sessionId.isNullOrBlank()) {
routeIncomingSession(sessionId)
openSessionDetail(sessionId)
return
}
maybeHandleDebugIntent(intent)
}
private fun routeIncomingSession(sessionId: String) {
thread {
val draftSession = runCatching {
findStandaloneHomeDraftSession(sessionId)
}.getOrElse { err ->
Log.w(TAG, "Failed to inspect incoming session $sessionId", err)
null
}
runOnUiThread {
if (draftSession != null) {
showCreateSessionDialog(
initialTargetSelection = CreateSessionDialogController.InitialTargetSelection(
packageName = checkNotNull(draftSession.targetPackage),
locked = true,
),
existingSessionId = draftSession.sessionId,
initialSettings = agentSessionController.executionSettingsForSession(draftSession.sessionId),
)
} else {
openSessionDetail(sessionId)
}
}
}
}
private fun maybeHandleDebugIntent(intent: Intent?) {
when (intent?.action) {
ACTION_DEBUG_CANCEL_ALL_AGENT_SESSIONS -> {
@@ -233,120 +199,10 @@ class MainActivity : Activity() {
Log.w(TAG, "Failed to start debug Agent session", err)
showToast("Failed to start Agent session: ${err.message}")
}
result.onSuccess(::handleSessionStarted)
}
}
private fun showCreateSessionDialog(
initialTargetSelection: CreateSessionDialogController.InitialTargetSelection? = null,
existingSessionId: String? = null,
initialSettings: SessionExecutionSettings = SessionExecutionSettings(
model = latestAgentRuntimeStatus?.effectiveModel,
reasoningEffort = null,
),
) {
if (availableModels.isEmpty()) {
refreshModelsIfNeeded(force = true) {
runOnUiThread {
openCreateSessionDialog(
initialTargetSelection = initialTargetSelection,
existingSessionId = existingSessionId,
initialSettings = initialSettings,
)
}
}
showToast("Loading model catalog…")
return
}
openCreateSessionDialog(
initialTargetSelection = initialTargetSelection,
existingSessionId = existingSessionId,
initialSettings = initialSettings,
)
}
private fun openCreateSessionDialog(
initialTargetSelection: CreateSessionDialogController.InitialTargetSelection? = null,
existingSessionId: String? = null,
initialSettings: SessionExecutionSettings,
) {
createSessionDialogController.show(
models = availableModels,
initialPrompt = "",
initialSettings = initialSettings,
initialTargetSelection = initialTargetSelection,
existingSessionId = existingSessionId,
onSubmit = ::startSessionFromUi,
)
}
private fun startSessionFromUi(request: LaunchSessionRequest) {
thread {
val result = runCatching {
AgentSessionLauncher.startSession(
context = this,
request = request,
sessionController = agentSessionController,
requestUserInputHandler = { questions ->
AgentUserInputPrompter.promptForAnswers(this, questions)
},
)
}
result.onFailure { err ->
Log.w(TAG, "Failed to start Agent session", err)
showToast("Failed to start Agent session: ${err.message}")
result.onSuccess { started ->
showToast("Started session ${started.parentSessionId}")
refreshAgentSessions(force = true)
}
result.onSuccess(::handleSessionStarted)
}
}
private fun handleSessionStarted(sessionStart: SessionStartResult) {
Log.i(
TAG,
"Started session topLevel=${sessionStart.parentSessionId} anchor=${sessionStart.anchor} children=${sessionStart.childSessionIds}",
)
val targetSummary = sessionStart.plannedTargets.joinToString(", ")
showToast(
"Started ${sessionStart.childSessionIds.size} session(s) for $targetSummary via ${sessionStart.geniePackage}",
)
refreshAgentSessions(force = true)
openSessionDetail(sessionStart.parentSessionId)
}
private fun refreshModelsIfNeeded(
force: Boolean,
onComplete: (() -> Unit)? = null,
) {
if (!force && availableModels.isNotEmpty()) {
onComplete?.invoke()
return
}
if (onComplete != null) {
synchronized(pendingModelCallbacks) {
pendingModelCallbacks += onComplete
}
}
if (modelsRefreshInFlight) {
return
}
modelsRefreshInFlight = true
thread {
try {
runCatching { AgentCodexAppServerClient.listModels(this) }
.onFailure { err ->
Log.w(TAG, "Failed to load model catalog", err)
}
.onSuccess { models ->
availableModels = models
}
} finally {
modelsRefreshInFlight = false
val callbacks = synchronized(pendingModelCallbacks) {
pendingModelCallbacks.toList().also { pendingModelCallbacks.clear() }
}
callbacks.forEach { callback -> callback.invoke() }
}
}
}
@@ -389,18 +245,6 @@ class MainActivity : Activity() {
}
}
private fun findStandaloneHomeDraftSession(sessionId: String): AgentSessionDetails? {
val snapshot = agentSessionController.loadSnapshot(sessionId)
val session = snapshot.sessions.firstOrNull { it.sessionId == sessionId } ?: return null
val hasChildren = snapshot.sessions.any { it.parentSessionId == sessionId }
return session.takeIf {
it.anchor == AgentSessionInfo.ANCHOR_HOME &&
it.state == AgentSessionInfo.STATE_CREATED &&
!hasChildren &&
!it.targetPackage.isNullOrBlank()
}
}
private fun updateFrameworkStatus(snapshot: AgentSnapshot) {
val roleHolders = if (snapshot.roleHolders.isEmpty()) {
"none"
@@ -559,6 +403,19 @@ class MainActivity : Activity() {
)
}
private fun launchCreateSessionActivity() {
startActivity(
CreateSessionActivity.newSessionIntent(
context = this,
initialSettings = SessionExecutionSettings(
model = latestAgentRuntimeStatus?.effectiveModel,
reasoningEffort = null,
),
),
)
moveTaskToBack(true)
}
private fun showToast(message: String) {
runOnUiThread {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()

View File

@@ -14,7 +14,6 @@ import android.widget.EditText
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import com.openai.codex.bridge.SessionExecutionSettings
import kotlin.concurrent.thread
class SessionDetailActivity : Activity() {
@@ -33,9 +32,6 @@ class SessionDetailActivity : Activity() {
)
private val sessionController by lazy { AgentSessionController(this) }
private val createSessionDialogController by lazy {
CreateSessionDialogController(this, sessionController)
}
private val dismissedSessionStore by lazy { DismissedSessionStore(this) }
private val sessionUiLeaseToken = Binder()
private var leasedSessionId: String? = null
@@ -44,10 +40,6 @@ class SessionDetailActivity : Activity() {
private var selectedChildSessionId: String? = null
private var latestSnapshot: AgentSnapshot = AgentSnapshot.unavailable
private var refreshInFlight = false
@Volatile
private var modelsRefreshInFlight = false
private var availableModels: List<AgentModelOption> = emptyList()
private val pendingModelCallbacks = mutableListOf<() -> Unit>()
private val sessionListener = object : AgentManager.SessionListener {
override fun onSessionChanged(session: AgentSessionInfo) {
@@ -72,7 +64,6 @@ class SessionDetailActivity : Activity() {
override fun onResume() {
super.onResume()
registerSessionListenerIfNeeded()
refreshModelsIfNeeded(force = availableModels.isEmpty())
refreshSnapshot(force = true)
}
@@ -140,7 +131,7 @@ class SessionDetailActivity : Activity() {
Log.i(TAG, "Debug continuation reused topLevel=${result.parentSessionId}")
showToast("Continued session in place")
runOnUiThread {
startActivity(intentForSession(result.parentSessionId))
moveTaskToBack(true)
}
}
}
@@ -304,7 +295,7 @@ class SessionDetailActivity : Activity() {
findViewById<Button>(R.id.session_detail_follow_up_button).apply {
visibility = continueVisibility
text = if (canStartStandaloneHomeSession) {
"Open Start Dialog"
"Start Session"
} else {
"Send Continuation Prompt"
}
@@ -557,53 +548,15 @@ class SessionDetailActivity : Activity() {
val targetPackage = checkNotNull(topLevelSession.targetPackage) {
"No target package available for this session"
}
val initialSettings = sessionController.executionSettingsForSession(topLevelSession.sessionId)
val openDialog = {
runOnUiThread {
createSessionDialogController.show(
models = availableModels,
initialPrompt = "",
initialSettings = initialSettings,
initialTargetSelection = CreateSessionDialogController.InitialTargetSelection(
packageName = targetPackage,
locked = true,
),
existingSessionId = topLevelSession.sessionId,
) { request ->
startExistingHomeSessionFromDialog(request)
}
}
}
if (modelsRefreshInFlight && availableModels.isEmpty()) {
refreshModelsIfNeeded(force = true, onComplete = openDialog)
showToast("Loading model catalog…")
return
}
if (availableModels.isEmpty()) {
refreshModelsIfNeeded(force = true, onComplete = openDialog)
showToast("Loading model catalog…")
return
}
openDialog.invoke()
}
private fun startExistingHomeSessionFromDialog(request: LaunchSessionRequest) {
thread {
runCatching {
AgentSessionLauncher.startSession(
context = this,
request = request,
sessionController = sessionController,
)
}.onFailure { err ->
showToast("Failed to start session: ${err.message}")
}.onSuccess { result ->
showToast("Started session")
runOnUiThread {
startActivity(intentForSession(result.parentSessionId))
}
}
}
startActivity(
CreateSessionActivity.existingHomeSessionIntent(
context = this,
sessionId = topLevelSession.sessionId,
targetPackage = targetPackage,
initialSettings = sessionController.executionSettingsForSession(topLevelSession.sessionId),
),
)
moveTaskToBack(true)
}
private fun continueSessionInPlaceAsync(
@@ -618,9 +571,7 @@ class SessionDetailActivity : Activity() {
}.onSuccess { result ->
showToast("Continued session in place")
runOnUiThread {
startActivity(
intentForSession(result.parentSessionId),
)
moveTaskToBack(true)
}
}
}
@@ -730,10 +681,6 @@ class SessionDetailActivity : Activity() {
}
}
private fun intentForSession(sessionId: String) =
android.content.Intent(this, SessionDetailActivity::class.java)
.putExtra(EXTRA_SESSION_ID, sessionId)
private fun isTerminalState(state: Int): Boolean {
return state == AgentSessionInfo.STATE_COMPLETED ||
state == AgentSessionInfo.STATE_CANCELLED ||
@@ -749,40 +696,4 @@ class SessionDetailActivity : Activity() {
private fun dp(value: Int): Int {
return (value * resources.displayMetrics.density).toInt()
}
private fun refreshModelsIfNeeded(
force: Boolean,
onComplete: (() -> Unit)? = null,
) {
if (!force && availableModels.isNotEmpty()) {
onComplete?.invoke()
return
}
if (onComplete != null) {
synchronized(pendingModelCallbacks) {
pendingModelCallbacks += onComplete
}
}
if (modelsRefreshInFlight) {
return
}
modelsRefreshInFlight = true
thread {
try {
runCatching { AgentCodexAppServerClient.listModels(this) }
.onFailure { err ->
Log.w(TAG, "Failed to load model catalog", err)
}
.onSuccess { models ->
availableModels = models
}
} finally {
modelsRefreshInFlight = false
val callbacks = synchronized(pendingModelCallbacks) {
pendingModelCallbacks.toList().also { pendingModelCallbacks.clear() }
}
callbacks.forEach { callback -> callback.invoke() }
}
}
}
}

View File

@@ -0,0 +1,118 @@
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:id="@+id/create_session_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="New Session"
android:textAppearance="?android:attr/textAppearanceLarge"
android:textStyle="bold" />
<TextView
android:id="@+id/create_session_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Loading session…"
android:visibility="gone" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Target app" />
<TextView
android:id="@+id/create_session_target_summary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="4dp"
android:text="No target app selected. This will start an Agent-anchored session." />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<Button
android:id="@+id/create_session_pick_target_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Choose Target App" />
<Button
android:id="@+id/create_session_clear_target_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="Clear" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Prompt" />
<EditText
android:id="@+id/create_session_prompt"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="top|start"
android:inputType="textMultiLine|textCapSentences"
android:minLines="4" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Model" />
<Spinner
android:id="@+id/create_session_model_spinner"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Thinking depth" />
<Spinner
android:id="@+id/create_session_effort_spinner"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:gravity="end"
android:orientation="horizontal">
<Button
android:id="@+id/create_session_cancel_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Cancel" />
<Button
android:id="@+id/create_session_start_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:text="Start" />
</LinearLayout>
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,7 @@
<resources>
<style name="CodexCreateSessionTheme" parent="@android:style/Theme.DeviceDefault.Dialog.NoActionBar">
<item name="android:windowCloseOnTouchOutside">true</item>
<item name="android:windowMinWidthMajor">90%</item>
<item name="android:windowMinWidthMinor">90%</item>
</style>
</resources>