Integrate typed SSD AI report flow

ober

2e06423e40fd64a3ff3073b266f2f2eba1519053

diff --git a/templates/ssd-review-parts/main-ai-flow.ss b/templates/ssd-review-parts/main-ai-flow.ss
new file mode 100644
index 0000000..ca3e1b4
--- /dev/null
+++ b/templates/ssd-review-parts/main-ai-flow.ss
@@ -0,0 +1,750 @@
+(import (jerboa prelude))
+
+(def fragment
+  '((typed-kotlin-file "com/sfb/ssdreview/MainAiFlow.kt"
+      (kotlin-imports (android app AlertDialog)
+                      (android app AlertDialog Builder)
+                      (android content Context)
+                      (android content DialogInterface)
+                      (android content Intent)
+                      (android content SharedPreferences)
+                      (android content SharedPreferences Editor)
+                      (android content pm PackageManager)
+                      (android graphics Bitmap)
+                      (android graphics Bitmap Config)
+                      (android graphics Bitmap CompressFormat)
+                      (android graphics Canvas)
+                      (android net Uri)
+                      (android os Build)
+                      (android provider Settings)
+                      (android text Editable)
+                      (android widget EditText)
+                      (android widget ScrollView)
+                      (android widget TextView)
+                      (androidx core content FileProvider)
+                      (java io ByteArrayOutputStream)
+                      (java io File)
+                      (org json JSONObject))
+      (typed-library (com sfb ssdreview)
+        (export mainAiShowIssueDialog mainAiOnResume)
+        (type AlertDialog)
+        (type Bitmap)
+        (type Builder)
+        (type ByteArrayOutputStream)
+        (type Canvas)
+        (type CompressFormat)
+        (type Config)
+        (type DialogInterface)
+        (type Editable)
+        (type Editor)
+        (type EditText)
+        (type Exception)
+        (type File)
+        (type IllegalStateException)
+        (type Int)
+        (type Int32)
+        (type Intent)
+        (type MainActivity)
+        (type JSONObject)
+        (type ScrollView)
+        (type SfbAiApkArtifact)
+        (type SfbAiPollState)
+        (type SfbAiReportClient)
+        (type SfbAiReportRequest)
+        (type SfbAiReportResult)
+        (type SfbAiReportSubmission)
+        (type SharedPreferences)
+        (type TextView)
+        (type Uri)
+        (extern (mainAiClient
+                  (activity : MainActivity)) : SfbAiReportClient
+          (kotlin-member-get aiReportClient))
+        (extern (mainAiPollingJob
+                  (activity : MainActivity)) : String
+          (kotlin-member-get aiPollingJobId))
+        (extern (mainAiPollingJobSet
+                  (activity : MainActivity)
+                  (jobId : String)) : Unit
+          (kotlin-member-set aiPollingJobId))
+        (extern (mainAiReviewView
+                  (activity : MainActivity)) : SsdReviewView
+          (kotlin-member-get reviewView))
+        (type SsdReviewView)
+        (extern (mainAiPdfName
+                  (activity : MainActivity)) : String
+          (kotlin-member-get pdfName))
+        (extern (mainAiPage
+                  (activity : MainActivity)) : Int32
+          (kotlin-member-get pageIndex))
+        (extern (mainAiPageCount
+                  (activity : MainActivity)) : Int32
+          (kotlin-member-get pageCount))
+        (extern (mainAiSelectedGroup
+                  (activity : MainActivity)) : (Nullable String)
+          (kotlin-member-get selectedGroupId))
+        (extern (mainAiIntent
+                  (activity : MainActivity)) : Intent
+          (kotlin-member-get intent))
+        (extern (mainAiIntentBooleanExtra
+                  (intent : Intent)
+                  (key : String)
+                  (fallback : Bool)) : Bool
+          (kotlin-member-call getBooleanExtra))
+        (extern (mainAiAppInfo
+                  (activity : MainActivity)) : JSONObject
+          (kotlin-call mainActivityAppLogInfo))
+        (extern (mainAiDeviceInfo) : JSONObject
+          (kotlin-call mainActivityDeviceLogInfo))
+        (extern (mainAiSetStatus
+                  (activity : MainActivity)
+                  (status : String)) : Unit
+          (kotlin-call mainActivitySetStatusRaw))
+        (extern (mainAiLogEvent
+                  (activity : MainActivity)
+                  (event : String)
+                  (fields : JSONObject)) : Unit
+          (kotlin-call mainActivityLogClientEvent))
+        (extern (mainAiRunBackground
+                  (name : String)
+                  (action : (-> Unit))) : Unit
+          (kotlin-call runBackgroundNamed))
+        (extern (mainAiRunOnUi
+                  (activity : MainActivity)
+                  (action : (-> Unit))) : Unit
+          (kotlin-member-call runOnUiThread))
+        (extern (mainAiViewWidth
+                  (view : SsdReviewView)) : Int32
+          (kotlin-member-get width))
+        (extern (mainAiViewHeight
+                  (view : SsdReviewView)) : Int32
+          (kotlin-member-get height))
+        (extern (mainAiViewDraw
+                  (view : SsdReviewView)
+                  (canvas : Canvas)) : Unit
+          (kotlin-member-call draw))
+        (extern (mainAiBitmapConfig) : Config
+          (kotlin-value Bitmap Config ARGB_8888))
+        (extern (mainAiBitmapCreate
+                  (width : Int32)
+                  (height : Int32)
+                  (config : Config)) : Bitmap
+          (kotlin-call Bitmap createBitmap))
+        (extern (mainAiCanvas (bitmap : Bitmap)) : Canvas
+          (kotlin-call Canvas))
+        (extern (mainAiJpeg) : CompressFormat
+          (kotlin-value Bitmap CompressFormat JPEG))
+        (extern (mainAiOutput) : ByteArrayOutputStream
+          (kotlin-call ByteArrayOutputStream))
+        (extern (mainAiBitmapCompress
+                  (bitmap : Bitmap)
+                  (format : CompressFormat)
+                  (quality : Int32)
+                  (output : ByteArrayOutputStream)) : Bool
+          (kotlin-member-call compress))
+        (extern (mainAiOutputBytes
+                  (output : ByteArrayOutputStream)) : Bytes
+          (kotlin-member-call toByteArray))
+        (extern (mainAiBytesSize (bytes : Bytes)) : Int32
+          (kotlin-member-get size))
+        (extern (mainAiBitmapRecycle (bitmap : Bitmap)) : Unit
+          (kotlin-member-call recycle))
+        (extern (mainAiIllegalState
+                  (message : String)) : IllegalStateException
+          (kotlin-call IllegalStateException))
+        (extern (mainAiEditText
+                  (activity : MainActivity)) : EditText
+          (kotlin-call EditText))
+        (extern (mainAiEditTextHintSet
+                  (input : EditText)
+                  (hint : String)) : Unit
+          (kotlin-member-set hint))
+        (extern (mainAiEditTextEditable
+                  (input : EditText)) : Editable
+          (kotlin-member-call getText))
+        (extern (mainAiEditableText
+                  (editable : Editable)) : String
+          (kotlin-member-call toString))
+        (extern (mainAiBuilder
+                  (activity : MainActivity)) : Builder
+          (kotlin-call AlertDialog Builder))
+        (extern (mainAiBuilderTitle
+                  (builder : Builder)
+                  (title : String)) : Builder
+          (kotlin-member-call setTitle))
+        (extern (mainAiBuilderMessage
+                  (builder : Builder)
+                  (message : String)) : Builder
+          (kotlin-member-call setMessage))
+        (extern (mainAiBuilderEditView
+                  (builder : Builder)
+                  (view : EditText)) : Builder
+          (kotlin-member-call setView))
+        (extern (mainAiBuilderScrollView
+                  (builder : Builder)
+                  (view : ScrollView)) : Builder
+          (kotlin-member-call setView))
+        (extern (mainAiBuilderNegative
+                  (builder : Builder)
+                  (label : String)
+                  (listener : (-> DialogInterface Int32 Unit))) : Builder
+          (kotlin-member-call setNegativeButton))
+        (extern (mainAiBuilderPositive
+                  (builder : Builder)
+                  (label : String)
+                  (listener : (-> DialogInterface Int32 Unit))) : Builder
+          (kotlin-member-call setPositiveButton))
+        (extern (mainAiBuilderShow (builder : Builder)) : AlertDialog
+          (kotlin-member-call show))
+        (extern (mainAiTextView
+                  (activity : MainActivity)) : TextView
+          (kotlin-call TextView))
+        (extern (mainAiTextViewSet
+                  (view : TextView)
+                  (text : String)) : Unit
+          (kotlin-member-call setText))
+        (extern (mainAiTextViewSelectableSet
+                  (view : TextView)
+                  (enabled : Bool)) : Unit
+          (kotlin-member-call setTextIsSelectable))
+        (extern (mainAiScrollView
+                  (activity : MainActivity)) : ScrollView
+          (kotlin-call ScrollView))
+        (extern (mainAiScrollAddText
+                  (scroll : ScrollView)
+                  (view : TextView)) : Unit
+          (kotlin-member-call addView))
+        (extern (mainAiSubmit
+                  (client : SfbAiReportClient)
+                  (request : SfbAiReportRequest)) : SfbAiReportSubmission
+          (kotlin-member-call submit))
+        (extern (mainAiSubmissionJobId
+                  (submission : SfbAiReportSubmission)) : String
+          (kotlin-member-get jobId))
+        (extern (mainAiSubmissionState
+                  (submission : SfbAiReportSubmission)) : String
+          (kotlin-member-get state))
+        (extern (mainAiPoll
+                  (client : SfbAiReportClient)
+                  (jobId : String)
+                  (maxPolls : Int32)
+                  (maxFailures : Int32)
+                  (intervalMs : Int)) : SfbAiPollState
+          (kotlin-member-call pollUntilTerminal))
+        (extern (mainAiPollResult
+                  (state : SfbAiPollState)) : (Nullable SfbAiReportResult)
+          (kotlin-member-get result))
+        (extern (mainAiPollPaused
+                  (state : SfbAiPollState)) : Bool
+          (kotlin-member-get paused))
+        (extern (mainAiResultState
+                  (result : SfbAiReportResult)) : String
+          (kotlin-member-get state))
+        (extern (mainAiResultFeedback
+                  (result : SfbAiReportResult)) : String
+          (kotlin-member-get feedback))
+        (extern (mainAiResultTranscript
+                  (result : SfbAiReportResult)) : String
+          (kotlin-member-get transcript))
+        (extern (mainAiResultApk
+                  (result : SfbAiReportResult)) : (Nullable SfbAiApkArtifact)
+          (kotlin-member-get apk))
+        (extern (mainAiDownloadReady
+                  (result : SfbAiReportResult)
+                  (allowDryRun : Bool)) : Bool
+          (kotlin-call sfbAiResultDownloadReady))
+        (extern (mainAiDownloadApk
+                  (client : SfbAiReportClient)
+                  (artifact : SfbAiApkArtifact)) : File
+          (kotlin-member-call downloadApk))
+        (extern (mainAiModePrivate) : Int32
+          (kotlin-value Context MODE_PRIVATE))
+        (extern (mainAiPreferences
+                  (activity : MainActivity)
+                  (name : String)
+                  (mode : Int32)) : SharedPreferences
+          (kotlin-member-call getSharedPreferences))
+        (extern (mainAiPreferencesGetString
+                  (preferences : SharedPreferences)
+                  (key : String)
+                  (fallback : (Nullable String))) : (Nullable String)
+          (kotlin-member-call getString))
+        (extern (mainAiPreferencesEdit
+                  (preferences : SharedPreferences)) : Editor
+          (kotlin-member-call edit))
+        (extern (mainAiEditorPutString
+                  (editor : Editor)
+                  (key : String)
+                  (value : String)) : Editor
+          (kotlin-member-call putString))
+        (extern (mainAiEditorRemove
+                  (editor : Editor)
+                  (key : String)) : Editor
+          (kotlin-member-call remove))
+        (extern (mainAiEditorApply (editor : Editor)) : Unit
+          (kotlin-member-call apply))
+        (extern (mainAiExceptionMessage
+                  (error : Exception)) : (Nullable String)
+          (kotlin-member-get message))
+        (extern (mainAiCacheDir
+                  (activity : MainActivity)) : File
+          (kotlin-member-get cacheDir))
+        (extern (mainAiFile
+                  (parent : File)
+                  (leaf : String)) : File
+          (kotlin-call File))
+        (extern (mainAiFileFromPath (path : String)) : File
+          (kotlin-call File))
+        (extern (mainAiCanonicalFile (file : File)) : File
+          (kotlin-member-get canonicalFile))
+        (extern (mainAiFileParent (file : File)) : (Nullable File)
+          (kotlin-member-get parentFile))
+        (extern (mainAiFileIsFile (file : File)) : Bool
+          (kotlin-member-get isFile))
+        (extern (mainAiFilePath (file : File)) : String
+          (kotlin-member-get absolutePath))
+        (extern (mainAiPackageManager
+                  (activity : MainActivity)) : PackageManager
+          (kotlin-member-get packageManager))
+        (type PackageManager)
+        (extern (mainAiCanInstall
+                  (manager : PackageManager)) : Bool
+          (kotlin-member-call canRequestPackageInstalls))
+        (extern (mainAiSdk) : Int32
+          (kotlin-value Build VERSION SDK_INT))
+        (extern (mainAiApiO) : Int32
+          (kotlin-value Build VERSION_CODES O))
+        (extern (mainAiPackageName
+                  (activity : MainActivity)) : String
+          (kotlin-member-get packageName))
+        (extern (mainAiIntentWithUri
+                  (action : String)
+                  (uri : Uri)) : Intent
+          (kotlin-call Intent))
+        (extern (mainAiIntentForAction
+                  (action : String)) : Intent
+          (kotlin-call Intent))
+        (extern (mainAiUnknownSourcesAction) : String
+          (kotlin-value Settings ACTION_MANAGE_UNKNOWN_APP_SOURCES))
+        (extern (mainAiViewAction) : String
+          (kotlin-value Intent ACTION_VIEW))
+        (extern (mainAiUriParse (text : String)) : Uri
+          (kotlin-call Uri parse))
+        (extern (mainAiFileProviderUri
+                  (activity : MainActivity)
+                  (authority : String)
+                  (file : File)) : Uri
+          (kotlin-call FileProvider getUriForFile))
+        (extern (mainAiIntentDataType
+                  (intent : Intent)
+                  (uri : Uri)
+                  (mime : String)) : Intent
+          (kotlin-member-call setDataAndType))
+        (extern (mainAiIntentAddFlags
+                  (intent : Intent)
+                  (flags : Int32)) : Intent
+          (kotlin-member-call addFlags))
+        (extern (mainAiReadUriFlag) : Int32
+          (kotlin-value Intent FLAG_GRANT_READ_URI_PERMISSION))
+        (extern (mainAiStartActivity
+                  (activity : MainActivity)
+                  (intent : Intent)) : Unit
+          (kotlin-member-call startActivity))
+        (def (mainAiSelfTest (activity : MainActivity)) : Bool
+          (mainAiIntentBooleanExtra
+            (mainAiIntent activity)
+            "sfb.ssdreview.selftest"
+            #f))
+        (def (mainAiErrorText (error : Exception)) : String
+          (let ((message (mainAiExceptionMessage error)))
+            (if (nullable-null? message) "unknown" (nullable-get message))))
+        (def (mainAiSelectedGroupText
+               (activity : MainActivity)) : String
+          (let ((selected (mainAiSelectedGroup activity)))
+            (if (nullable-null? selected) "" (nullable-get selected))))
+        (def (mainAiStatePreferences
+               (activity : MainActivity)) : SharedPreferences
+          (mainAiPreferences activity "ssd_ai_state" (mainAiModePrivate)))
+        (def (mainAiStoreString
+               (activity : MainActivity)
+               (key : String)
+               (value : String)) : Unit
+          (mainAiEditorApply
+            (mainAiEditorPutString
+              (mainAiPreferencesEdit (mainAiStatePreferences activity))
+              key
+              value)))
+        (def (mainAiRemove
+               (activity : MainActivity)
+               (key : String)) : Unit
+          (mainAiEditorApply
+            (mainAiEditorRemove
+              (mainAiPreferencesEdit (mainAiStatePreferences activity))
+              key)))
+        (def (mainAiStoredString
+               (activity : MainActivity)
+               (key : String)) : String
+          (let ((value
+                  (mainAiPreferencesGetString
+                    (mainAiStatePreferences activity)
+                    key
+                    (nullable-none String))))
+            (if (nullable-null? value) "" (string-trim (nullable-get value)))))
+        (def (mainAiCaptureDisplay
+               (activity : MainActivity)) : Bytes
+          (let ((view (mainAiReviewView activity)))
+            (let ((width (mainAiViewWidth view))
+                  (height (mainAiViewHeight view)))
+              (if (or (<= width (int32 0)) (<= height (int32 0)))
+                (throw (mainAiIllegalState "Display is not ready") Bytes)
+                (let ((bitmap
+                        (mainAiBitmapCreate
+                          width height (mainAiBitmapConfig))))
+                  (try-finally
+                    (begin
+                      (mainAiViewDraw view (mainAiCanvas bitmap))
+                      (let ((output (mainAiOutput)))
+                        (begin
+                          (if (mainAiBitmapCompress
+                                bitmap (mainAiJpeg) (int32 85) output)
+                            (begin)
+                            (throw
+                              (mainAiIllegalState "JPEG capture failed")
+                              Unit))
+                          (let ((bytes (mainAiOutputBytes output)))
+                            (if (<= (mainAiBytesSize bytes)
+                                    (* (* (int32 4) (int32 1024))
+                                       (int32 1024)))
+                              bytes
+                              (throw
+                                (mainAiIllegalState
+                                  "Captured display exceeds 4 MiB")
+                                Bytes))))))
+                    (mainAiBitmapRecycle bitmap)))))))
+        (def (mainAiIssueRequest
+               (activity : MainActivity)
+               (description : String)
+               (screenshot : Bytes)) : SfbAiReportRequest
+          (new SfbAiReportRequest
+            description
+            screenshot
+            (mainAiSelfTest activity)
+            (mainAiAppInfo activity)
+            (mainAiDeviceInfo)
+            (mainAiPdfName activity)
+            (+ (mainAiPage activity) (int32 1))
+            (mainAiPageCount activity)
+            (mainAiSelectedGroupText activity)
+            ""))
+        (def (mainAiSubmissionFields
+               (submission : SfbAiReportSubmission)
+               (screenshotBytes : Int32)) : JSONObject
+          (let ((fields (json-object-empty)))
+            (begin
+              (json-object-put-string!
+                fields "job_id" (mainAiSubmissionJobId submission))
+              (json-object-put-string!
+                fields "state" (mainAiSubmissionState submission))
+              (json-object-put-int32!
+                fields "screenshot_bytes" screenshotBytes)
+              fields)))
+        (def (mainAiResultText
+               (result : SfbAiReportResult)
+               (apk : (Nullable File))
+               (downloadError : String)) : String
+          (let ((feedback (mainAiResultFeedback result))
+                (transcript (mainAiResultTranscript result)))
+            (let ((details
+                    (if (or (string-blank? transcript)
+                            (equal? transcript feedback))
+                      feedback
+                      (string-append
+                        feedback
+                        (string-append
+                          "\n\nFull agent transcript:\n"
+                          transcript)))))
+              (if (nullable-null? apk)
+                (if (string-blank? downloadError)
+                  details
+                  (string-append
+                    details
+                    (string-append
+                      "\n\nAPK download failed: "
+                      downloadError)))
+                (string-append
+                  details
+                  "\n\nVerified APK update downloaded to this phone.")))))
+        (def (mainAiInstallApk
+               (activity : MainActivity)
+               (apk : File)) : Unit
+          (let ((updates
+                  (mainAiCanonicalFile
+                    (mainAiFile (mainAiCacheDir activity) "updates")))
+                (candidate (mainAiCanonicalFile apk)))
+            (if (and (mainAiFileIsFile candidate)
+                     (and (not (nullable-null? (mainAiFileParent candidate)))
+                          (equal? (nullable-get (mainAiFileParent candidate))
+                                  updates)))
+              (if (and (>= (mainAiSdk) (mainAiApiO))
+                       (not (mainAiCanInstall
+                              (mainAiPackageManager activity))))
+                (begin
+                  (mainAiStoreString
+                    activity "pending_apk" (mainAiFilePath candidate))
+                  (mainAiStartActivity
+                    activity
+                    (mainAiIntentWithUri
+                      (mainAiUnknownSourcesAction)
+                      (mainAiUriParse
+                        (string-append
+                          "package:"
+                          (mainAiPackageName activity)))))
+                  (mainAiSetStatus
+                    activity
+                    "Allow installs from this app, then the update will open"))
+                (let ((uri
+                        (mainAiFileProviderUri
+                          activity
+                          (string-append
+                            (mainAiPackageName activity)
+                            ".files")
+                          candidate)))
+                  (begin
+                    (mainAiRemove activity "pending_apk")
+                    (mainAiStartActivity
+                      activity
+                      (mainAiIntentAddFlags
+                        (mainAiIntentDataType
+                          (mainAiIntentForAction (mainAiViewAction))
+                          uri
+                          "application/vnd.android.package-archive")
+                        (mainAiReadUriFlag)))
+                    (mainAiSetStatus activity "Opening verified APK update"))))
+              (throw
+                (mainAiIllegalState "Invalid APK update file")
+                Unit))))
+        (def (mainAiShowResult
+               (activity : MainActivity)
+               (result : SfbAiReportResult)
+               (apk : (Nullable File))
+               (downloadError : String)) : Unit
+          (let ((text (mainAiTextView activity))
+                (scroll (mainAiScrollView activity)))
+            (begin
+              (mainAiTextViewSet
+                text
+                (mainAiResultText result apk downloadError))
+              (mainAiTextViewSelectableSet text #t)
+              (mainAiScrollAddText scroll text)
+              (if (nullable-null? apk)
+                (begin
+                  (mainAiBuilderShow
+                    (mainAiBuilderNegative
+                      (mainAiBuilderScrollView
+                        (mainAiBuilderTitle
+                          (mainAiBuilder activity)
+                          "AI Repair Result")
+                        scroll)
+                      "Close"
+                      (lambda ((unused : DialogInterface) (which : Int32))
+                        (begin))))
+                  (begin))
+                (begin
+                  (mainAiBuilderShow
+                    (mainAiBuilderPositive
+                      (mainAiBuilderNegative
+                        (mainAiBuilderScrollView
+                          (mainAiBuilderTitle
+                            (mainAiBuilder activity)
+                            "AI Repair Complete - Update Ready")
+                          scroll)
+                        "Close"
+                        (lambda ((unused : DialogInterface) (which : Int32))
+                          (begin)))
+                      "Install Update"
+                      (lambda ((unused : DialogInterface) (which : Int32))
+                        (mainAiInstallApk activity (nullable-get apk)))))
+                  (begin)))
+              (mainAiSetStatus
+                activity
+                (if (nullable-null? apk)
+                  (string-append
+                    "AI repair "
+                    (string-append
+                      (mainAiResultState result)
+                      "; feedback received"))
+                  "AI repair complete; APK update ready")))))
+        (def (mainAiPollJob
+               (activity : MainActivity)
+               (jobId : String)) : Unit
+          (if (or (string-blank? jobId)
+                  (equal? (mainAiPollingJob activity) jobId))
+            (begin)
+            (begin
+              (mainAiPollingJobSet activity jobId)
+              (mainAiRunBackground
+                "sfb-ai-report-poll"
+                (lambda ()
+                  (try-finally
+                    (try
+                      (let ((state
+                              (mainAiPoll
+                                (mainAiClient activity)
+                                jobId
+                                (int32 1200)
+                                (int32 1200)
+                                (if (mainAiSelfTest activity)
+                                  (int 100)
+                                  (int 3000)))))
+                        (let ((result (mainAiPollResult state)))
+                          (if (nullable-null? result)
+                            (mainAiRunOnUi
+                              activity
+                              (lambda ()
+                                (mainAiSetStatus
+                                  activity
+                                  "AI feedback polling paused; it will resume next time the app opens")))
+                            (let ((resolved (nullable-get result)))
+                              (begin
+                                (mainAiRemove activity "pending_ai_job")
+                                (let ((downloaded
+                                        (if (mainAiDownloadReady
+                                              resolved
+                                              (mainAiSelfTest activity))
+                                          (try
+                                            (nullable-some
+                                              (mainAiDownloadApk
+                                                (mainAiClient activity)
+                                                (nullable-get
+                                                  (mainAiResultApk resolved))))
+                                            (catch (error : Exception)
+                                              (nullable-none File)))
+                                          (nullable-none File))))
+                                  (mainAiRunOnUi
+                                    activity
+                                    (lambda ()
+                                      (mainAiShowResult
+                                        activity
+                                        resolved
+                                        downloaded
+                                        "")))))))))
+                      (catch (error : Exception)
+                        (mainAiRunOnUi
+                          activity
+                          (lambda ()
+                            (mainAiSetStatus
+                              activity
+                              (string-append
+                                "AI feedback polling failed: "
+                                (mainAiErrorText error)))))))
+                    (mainAiPollingJobSet activity "")))))))
+        (def (mainAiSubmitIssue
+               (activity : MainActivity)
+               (description : String)
+               (screenshot : Bytes)) : Unit
+          (begin
+            (mainAiSetStatus activity "Sending display to AI...")
+            (mainAiRunBackground
+              "sfb-ai-report-upload"
+              (lambda ()
+                (try
+                  (let ((submission
+                          (mainAiSubmit
+                            (mainAiClient activity)
+                            (mainAiIssueRequest
+                              activity description screenshot))))
+                    (let ((jobId (mainAiSubmissionJobId submission)))
+                      (begin
+                        (mainAiStoreString activity "pending_ai_job" jobId)
+                        (mainAiLogEvent
+                          activity
+                          "ssd-ai-report-submitted"
+                          (mainAiSubmissionFields
+                            submission
+                            (mainAiBytesSize screenshot)))
+                        (mainAiRunOnUi
+                          activity
+                          (lambda ()
+                            (mainAiSetStatus
+                              activity
+                              (string-append
+                                "AI report "
+                                (string-append
+                                  (mainAiSubmissionState submission)
+                                  (string-append
+                                    ": "
+                                    (string-append
+                                      jobId
+                                      "; waiting for full feedback")))))))
+                        (mainAiPollJob activity jobId))))
+                  (catch (error : Exception)
+                    (mainAiRunOnUi
+                      activity
+                      (lambda ()
+                        (mainAiSetStatus
+                          activity
+                          (string-append
+                            "AI report failed: "
+                            (mainAiErrorText error)))))))))))
+        (def (mainAiShowIssueDialog
+               (activity : MainActivity)) : Unit
+          (try
+            (let ((screenshot (mainAiCaptureDisplay activity))
+                  (input (mainAiEditText activity)))
+              (begin
+                (mainAiEditTextHintSet
+                  input
+                  "Describe what is wrong and what you expected")
+                (mainAiBuilderShow
+                  (mainAiBuilderPositive
+                    (mainAiBuilderNegative
+                      (mainAiBuilderEditView
+                        (mainAiBuilderMessage
+                          (mainAiBuilderTitle
+                            (mainAiBuilder activity)
+                            "Send Display to AI")
+                          "The current display and app context will be sent with your description.")
+                        input)
+                      "Cancel"
+                      (lambda ((unused : DialogInterface) (which : Int32))
+                        (begin)))
+                    "Send"
+                    (lambda ((unused : DialogInterface) (which : Int32))
+                      (let ((description
+                              (string-trim
+                                (mainAiEditableText
+                                  (mainAiEditTextEditable input)))))
+                        (if (string-blank? description)
+                          (mainAiSetStatus
+                            activity
+                            "Describe the issue before sending")
+                          (mainAiSubmitIssue
+                            activity description screenshot))))))
+                (begin)))
+            (catch (error : Exception)
+              (mainAiSetStatus
+                activity
+                (string-append
+                  "Display capture failed: "
+                  (mainAiErrorText error))))))
+        (def (mainAiResumePendingInstall
+               (activity : MainActivity)) : Unit
+          (let ((path (mainAiStoredString activity "pending_apk")))
+            (if (string-blank? path)
+              (begin)
+              (if (or (< (mainAiSdk) (mainAiApiO))
+                      (mainAiCanInstall (mainAiPackageManager activity)))
+                (try
+                  (mainAiInstallApk activity (mainAiFileFromPath path))
+                  (catch (error : Exception)
+                    (mainAiSetStatus
+                      activity
+                      (string-append
+                        "Unable to open APK update: "
+                        (mainAiErrorText error)))))
+                (begin)))))
+        (def (mainAiOnResume (activity : MainActivity)) : Unit
+          (begin
+            (mainAiPollJob
+              activity
+              (mainAiStoredString activity "pending_ai_job"))
+            (mainAiResumePendingInstall activity)))))))
diff --git a/templates/ssd-review-parts/server-ai.ss b/templates/ssd-review-parts/server-ai.ss
index baa6561..86549ee 100644
--- a/templates/ssd-review-parts/server-ai.ss
+++ b/templates/ssd-review-parts/server-ai.ss
@@ -8,4 +8,5 @@
     (include-fragment "ai-report.ss")
     (include-fragment "main-server-lifecycle.ss")
     (include-fragment "main-server-pages.ss")
-    (include-fragment "main-server-detect.ss")))
+    (include-fragment "main-server-detect.ss")
+    (include-fragment "main-ai-flow.ss")))