Add typed SSD AI report update client

ober

c0173c76a0043cfa7f268b44794787540989fdd7

diff --git a/templates/ssd-review-parts/ai-report.ss b/templates/ssd-review-parts/ai-report.ss
new file mode 100644
index 0000000..4706ae9
--- /dev/null
+++ b/templates/ssd-review-parts/ai-report.ss
@@ -0,0 +1,446 @@
+(import (jerboa prelude))
+
+(def fragment
+  '((typed-kotlin-file "com/sfb/ssdreview/SfbAiReportClient.kt"
+      (kotlin-imports (android content Context)
+                      (android util Base64)
+                      (java io File)
+                      (java nio channels FileChannel)
+                      (java nio file Files)
+                      (java nio file LinkOption)
+                      (java nio file Path)
+                      (java nio file StandardCopyOption)
+                      (java nio file StandardOpenOption)
+                      (org json JSONObject))
+      (typed-library (com sfb ssdreview)
+        (export make-SfbAiReportRequest SfbAiReportRequest?
+                SfbAiReportRequest-description SfbAiReportRequest-screenshot
+                SfbAiReportRequest-testMode SfbAiReportRequest-app
+                SfbAiReportRequest-device SfbAiReportRequest-pdfName
+                SfbAiReportRequest-page SfbAiReportRequest-pageCount
+                SfbAiReportRequest-selectedGroup SfbAiReportRequest-telemetry
+                make-SfbAiReportSubmission SfbAiReportSubmission?
+                SfbAiReportSubmission-jobId SfbAiReportSubmission-state
+                make-SfbAiApkArtifact SfbAiApkArtifact?
+                SfbAiApkArtifact-downloadUrl SfbAiApkArtifact-sha256
+                SfbAiApkArtifact-uploadedToTermux
+                make-SfbAiReportResult SfbAiReportResult?
+                SfbAiReportResult-jobId SfbAiReportResult-state
+                SfbAiReportResult-feedback SfbAiReportResult-transcript
+                SfbAiReportResult-apk
+                make-SfbAiPollState SfbAiPollState?
+                SfbAiPollState-result SfbAiPollState-failures
+                SfbAiPollState-polls SfbAiPollState-paused
+                sfbAiReportRequestJson sfbAiSubmissionFromJson
+                sfbAiResultFromJson sfbAiResultTerminal
+                sfbAiResultDownloadReady SfbAiReportClient)
+        (type Context)
+        (type Exception)
+        (type File)
+        (type FileChannel)
+        (type IllegalStateException)
+        (type Int32)
+        (type JSONObject)
+        (type LinkOption)
+        (type Path)
+        (type StandardCopyOption)
+        (type StandardOpenOption)
+        (type SfbHttpTransport)
+        (type SfbServerClient)
+        (record SfbAiReportRequest
+          ((description : String)
+           (screenshot : Bytes)
+           (testMode : Bool)
+           (app : JSONObject)
+           (device : JSONObject)
+           (pdfName : String)
+           (page : Int32)
+           (pageCount : Int32)
+           (selectedGroup : String)
+           (telemetry : String)))
+        (record SfbAiReportSubmission
+          ((jobId : String)
+           (state : String)))
+        (record SfbAiApkArtifact
+          ((downloadUrl : String)
+           (sha256 : String)
+           (uploadedToTermux : Bool)))
+        (record SfbAiReportResult
+          ((jobId : String)
+           (state : String)
+           (feedback : String)
+           (transcript : String)
+           (apk : (Nullable SfbAiApkArtifact))))
+        (record SfbAiPollState
+          ((result : (Nullable SfbAiReportResult))
+           (failures : Int32)
+           (polls : Int32)
+           (paused : Bool)))
+        (extern (sfbAiBase64NoWrap) : Int32
+          (kotlin-value Base64 NO_WRAP))
+        (extern (sfbAiIllegalStateException
+                  (message : String)) : IllegalStateException
+          (kotlin-call IllegalStateException))
+        (extern (sfbAiStringTakeLast
+                  (text : String)
+                  (count : Int32)) : String
+          (kotlin-member-call takeLast))
+        (extern (sfbAiJobIdValidValue (jobId : String)) : Bool
+          (kotlin-call sfbAiJobIdValid))
+        (extern (sfbAiUrlEncode (value : String)) : String
+          (kotlin-call urlEncode))
+        (extern (sfbAiApkSha256ValidValue (sha256 : String)) : Bool
+          (kotlin-call sfbApkSha256Valid))
+        (extern (sfbAiApkDownloadRelativeValue
+                  (downloadUrl : String)) : (Nullable String)
+          (kotlin-call sfbApkDownloadRelative))
+        (extern (sfbAiSha256Hex (bytes : Bytes)) : String
+          (kotlin-call truthStoreSha256Hex))
+        (extern (sfbAiPathParentMatches
+                  (path : Path)
+                  (parent : Path)) : Bool
+          (kotlin-call pathParentMatches))
+        (extern (sfbAiPathNotSymbolicLink (path : Path)) : Bool
+          (kotlin-call pathNotSymbolicLink))
+        (extern (sfbAiPageConnectTimeout) : Int32
+          (kotlin-value SFB_PAGE_CONNECT_TIMEOUT_MS))
+        (extern (sfbAiPageReadTimeout) : Int32
+          (kotlin-value SFB_PAGE_READ_TIMEOUT_MS))
+        (extern (sfbAiMaxApkBytes) : Int
+          (kotlin-value SFB_MAX_APK_BYTES))
+        (extern (sfbAiBase64Encode
+                  (bytes : Bytes)
+                  (flags : Int32)) : String
+          (kotlin-call Base64 encodeToString))
+        (extern (sfbAiServerTransport
+                  (client : SfbServerClient)) : SfbHttpTransport
+          (kotlin-member-call httpTransport))
+        (extern (sfbAiTransportPostJson
+                  (transport : SfbHttpTransport)
+                  (relative : String)
+                  (payload : JSONObject)
+                  (connectTimeoutMs : Int32)
+                  (readTimeoutMs : Int32)) : JSONObject
+          (kotlin-member-call postJson))
+        (extern (sfbAiTransportGetJsonUncached
+                  (transport : SfbHttpTransport)
+                  (relative : String)
+                  (connectTimeoutMs : Int32)
+                  (readTimeoutMs : Int32)) : JSONObject
+          (kotlin-member-call getJsonUncached))
+        (extern (sfbAiTransportGetBytesUncached
+                  (transport : SfbHttpTransport)
+                  (relative : String)
+                  (maxBytes : Int)
+                  (connectTimeoutMs : Int32)
+                  (readTimeoutMs : Int32)) : Bytes
+          (kotlin-member-call getBytesUncached))
+        (extern (sfbAiThreadSleep (millis : Int)) : Unit
+          (kotlin-call java lang Thread sleep))
+        (extern (sfbAiContextCacheDir (context : Context)) : File
+          (kotlin-member-get cacheDir))
+        (extern (sfbAiFileMkdirs (file : File)) : Bool
+          (kotlin-member-call mkdirs))
+        (extern (sfbAiFileToPath (file : File)) : Path
+          (kotlin-member-call toPath))
+        (extern (sfbAiPathToFile (path : Path)) : File
+          (kotlin-member-call toFile))
+        (extern (sfbAiPathResolve (path : Path) (leaf : String)) : Path
+          (kotlin-member-call resolve))
+        (extern (sfbAiPathNormalize (path : Path)) : Path
+          (kotlin-member-call normalize))
+        (extern (sfbAiPathToRealPath
+                  (path : Path)
+                  (option : LinkOption)) : Path
+          (kotlin-member-call toRealPath))
+        (extern (sfbAiNoFollowLinks) : LinkOption
+          (kotlin-value LinkOption NOFOLLOW_LINKS))
+        (extern (sfbAiFilesCreateTempFile
+                  (directory : Path)
+                  (prefix : String)
+                  (suffix : String)) : Path
+          (kotlin-call Files createTempFile))
+        (extern (sfbAiFilesWrite
+                  (path : Path)
+                  (bytes : Bytes)
+                  (truncate : StandardOpenOption)
+                  (write : StandardOpenOption)) : Path
+          (kotlin-call Files write))
+        (extern (sfbAiFileChannelOpen
+                  (path : Path)
+                  (write : StandardOpenOption)) : FileChannel
+          (kotlin-call FileChannel open))
+        (extern (sfbAiFileChannelForce
+                  (channel : FileChannel)
+                  (metadata : Bool)) : Unit
+          (kotlin-member-call force))
+        (extern (sfbAiFileChannelClose (channel : FileChannel)) : Unit
+          (kotlin-member-call close))
+        (extern (sfbAiFilesMove
+                  (source : Path)
+                  (target : Path)
+                  (atomic : StandardCopyOption)
+                  (replace : StandardCopyOption)) : Path
+          (kotlin-call Files move))
+        (extern (sfbAiFilesDeleteIfExists (path : Path)) : Bool
+          (kotlin-call Files deleteIfExists))
+        (extern (sfbAiOpenTruncate) : StandardOpenOption
+          (kotlin-value StandardOpenOption TRUNCATE_EXISTING))
+        (extern (sfbAiOpenWrite) : StandardOpenOption
+          (kotlin-value StandardOpenOption WRITE))
+        (extern (sfbAiCopyAtomic) : StandardCopyOption
+          (kotlin-value StandardCopyOption ATOMIC_MOVE))
+        (extern (sfbAiCopyReplace) : StandardCopyOption
+          (kotlin-value StandardCopyOption REPLACE_EXISTING))
+        (def (sfbAiRequire (condition : Bool) (message : String)) : Unit
+          (if condition
+            (begin)
+            (throw (sfbAiIllegalStateException message) Unit)))
+        (def (sfbAiDeleteIfExistsUnit (path : Path)) : Unit
+          (begin
+            (sfbAiFilesDeleteIfExists path)
+            (begin)))
+        (def (sfbAiDescription (description : String)) : String
+          (if (<= (string-length description) 4000)
+            description
+            (string-take description (int32 4000))))
+        (def (sfbAiTelemetry (telemetry : String)) : String
+          (if (<= (string-length telemetry) 262144)
+            telemetry
+            (sfbAiStringTakeLast telemetry (int32 262144))))
+        (def (sfbAiReportRequestJson
+               (request : SfbAiReportRequest)) : JSONObject
+          (let ((pdf (json-object-empty)))
+            (let ((out (json-object-empty)))
+              (begin
+                (json-object-put-string! pdf "name" (SfbAiReportRequest-pdfName request))
+                (json-object-put-int32! pdf "page" (SfbAiReportRequest-page request))
+                (json-object-put-int32! pdf "pages" (SfbAiReportRequest-pageCount request))
+                (json-object-put-string!
+                  out "description"
+                  (sfbAiDescription (SfbAiReportRequest-description request)))
+                (json-object-put-string!
+                  out "screenshot_base64"
+                  (sfbAiBase64Encode
+                    (SfbAiReportRequest-screenshot request)
+                    (sfbAiBase64NoWrap)))
+                (json-object-put-bool! out "test_mode" (SfbAiReportRequest-testMode request))
+                (json-object-put-bool! out "test_apk_update" (SfbAiReportRequest-testMode request))
+                (json-object-put-json-object! out "app" (SfbAiReportRequest-app request))
+                (json-object-put-json-object! out "device" (SfbAiReportRequest-device request))
+                (json-object-put-json-object! out "pdf" pdf)
+                (json-object-put-string!
+                  out "selected_group"
+                  (SfbAiReportRequest-selectedGroup request))
+                (json-object-put-string!
+                  out "client_telemetry"
+                  (sfbAiTelemetry (SfbAiReportRequest-telemetry request)))
+                out))))
+        (def (sfbAiSubmissionFromJson (json : JSONObject)) : SfbAiReportSubmission
+          (make-SfbAiReportSubmission
+            (json-object-opt-string json "job_id")
+            (json-object-opt-string-default json "state" "submitted")))
+        (def (sfbAiApkFromJson
+               (json : (Nullable JSONObject))) : (Nullable SfbAiApkArtifact)
+          (if (nullable-null? json)
+            (nullable-none SfbAiApkArtifact)
+            (let ((value (nullable-get json)))
+              (nullable-some
+                (make-SfbAiApkArtifact
+                  (json-object-opt-string value "download_url")
+                  (string-lowercase
+                    (string-trim (json-object-opt-string value "sha256")))
+                  (json-object-opt-bool-default
+                    value "uploaded_to_termux" #f))))))
+        (def (sfbAiResultFromJson (jobId : String)
+                                  (json : JSONObject)) : SfbAiReportResult
+          (let ((rawDetails (json-object-opt-json-object json "details")))
+            (let ((details
+                    (if (nullable-null? rawDetails)
+                      (json-object-empty)
+                      (nullable-get rawDetails))))
+            (let ((state (json-object-opt-string-default json "state" "unknown")))
+              (make-SfbAiReportResult
+                jobId
+                state
+                (json-object-opt-string-default
+                  details
+                  "feedback"
+                  (json-object-opt-string-default
+                    details
+                    "error"
+                    (string-append "AI job " state)))
+                (json-object-opt-string details "transcript")
+                (sfbAiApkFromJson
+                  (json-object-opt-json-object details "apk")))))))
+        (def (sfbAiResultTerminal (result : SfbAiReportResult)) : Bool
+          (let ((state (SfbAiReportResult-state result)))
+            (or (or (equal? state "complete") (equal? state "failed"))
+                (equal? state "dry-run"))))
+        (def (sfbAiResultDownloadReady
+               (result : SfbAiReportResult)
+               (allowDryRun : Bool)) : Bool
+          (let ((apk (SfbAiReportResult-apk result)))
+            (and (or (equal? (SfbAiReportResult-state result) "complete")
+                     (and allowDryRun
+                          (equal? (SfbAiReportResult-state result) "dry-run")))
+                 (and (not (nullable-null? apk))
+                      (SfbAiApkArtifact-uploadedToTermux
+                        (nullable-get apk))))))
+        (def (sfbAiPollStopped (state : SfbAiPollState)
+                               (maxFailures : Int32)) : Bool
+          (or (>= (SfbAiPollState-failures state) maxFailures)
+              (and (not (nullable-null? (SfbAiPollState-result state)))
+                   (sfbAiResultTerminal
+                     (nullable-get (SfbAiPollState-result state))))))
+        (def (sfbAiAtomicWrite (directory : File)
+                               (leaf : String)
+                               (bytes : Bytes)) : File
+          (begin
+            (sfbAiFileMkdirs directory)
+            (let ((parent
+                    (sfbAiPathToRealPath
+                      (sfbAiFileToPath directory)
+                      (sfbAiNoFollowLinks))))
+              (let ((target
+                      (sfbAiPathNormalize
+                        (sfbAiPathResolve parent leaf))))
+                (begin
+                  (sfbAiRequire
+                    (and (sfbAiPathParentMatches target parent)
+                         (sfbAiPathNotSymbolicLink target))
+                    "Unsafe APK update destination")
+                  (let ((temporary
+                          (sfbAiFilesCreateTempFile
+                            parent ".sfb-update-" ".part")))
+                    (try-finally
+                      (begin
+                        (sfbAiFilesWrite
+                          temporary bytes
+                          (sfbAiOpenTruncate)
+                          (sfbAiOpenWrite))
+                        (let ((channel
+                                (sfbAiFileChannelOpen
+                                  temporary
+                                  (sfbAiOpenWrite))))
+                          (try-finally
+                            (sfbAiFileChannelForce channel #t)
+                            (sfbAiFileChannelClose channel)))
+                        (sfbAiFilesMove
+                          temporary target
+                          (sfbAiCopyAtomic)
+                          (sfbAiCopyReplace))
+                        (sfbAiPathToFile target))
+                      (sfbAiDeleteIfExistsUnit temporary))))))))
+        (class SfbAiReportClient
+          ((context : Context (property val) (visibility private))
+           (server : SfbServerClient))
+          (val transport : SfbHttpTransport
+            (sfbAiServerTransport server))
+          (def (submit (request : SfbAiReportRequest)) : SfbAiReportSubmission
+            (let ((submission
+                    (sfbAiSubmissionFromJson
+                      (sfbAiTransportPostJson
+                        transport
+                        "ai-report"
+                        (sfbAiReportRequestJson request)
+                        (sfbAiPageConnectTimeout)
+                        (sfbAiPageReadTimeout)))))
+              (begin
+                (sfbAiRequire
+                  (sfbAiJobIdValidValue (SfbAiReportSubmission-jobId submission))
+                  "sfb-server returned an invalid AI job id")
+                submission)))
+          (def (fetch (jobId : String)) : SfbAiReportResult
+            (begin
+              (sfbAiRequire
+                (sfbAiJobIdValidValue jobId)
+                "Invalid AI job id")
+              (sfbAiResultFromJson
+                jobId
+                (sfbAiTransportGetJsonUncached
+                  transport
+                  (string-append "ai-report/" (sfbAiUrlEncode jobId))
+                  (sfbAiPageConnectTimeout)
+                  (sfbAiPageReadTimeout)))))
+          (def (pollUntilTerminal
+                 (jobId : String)
+                 (maxPolls : Int32)
+                 (maxFailures : Int32)
+                 (pollIntervalMs : Int)) : SfbAiPollState
+            (begin
+              (sfbAiRequire
+                (and (sfbAiJobIdValidValue jobId)
+                     (and (> maxPolls (int32 0))
+                          (> maxFailures (int32 0))))
+                "Invalid AI poll request")
+              (let ((state
+                      (for/fold
+                        ((current
+                           (make-SfbAiPollState
+                             (nullable-none SfbAiReportResult)
+                             (int32 0)
+                             (int32 0)
+                             #f)))
+                        ((i (in-range (int32 0) maxPolls)))
+                        (if (sfbAiPollStopped current maxFailures)
+                          current
+                          (try
+                            (let ((result (fetch jobId)))
+                              (begin
+                                (if (sfbAiResultTerminal result)
+                                  (begin)
+                                  (sfbAiThreadSleep pollIntervalMs))
+                                (make-SfbAiPollState
+                                  (nullable-some result)
+                                  (int32 0)
+                                  (+ (SfbAiPollState-polls current) (int32 1))
+                                  #f)))
+                            (catch (error : Exception)
+                              (begin
+                                (sfbAiThreadSleep pollIntervalMs)
+                                (make-SfbAiPollState
+                                  (SfbAiPollState-result current)
+                                  (+ (SfbAiPollState-failures current) (int32 1))
+                                  (+ (SfbAiPollState-polls current) (int32 1))
+                                  #f))))))))
+                (make-SfbAiPollState
+                  (SfbAiPollState-result state)
+                  (SfbAiPollState-failures state)
+                  (SfbAiPollState-polls state)
+                  (not
+                    (and (not (nullable-null? (SfbAiPollState-result state)))
+                         (sfbAiResultTerminal
+                           (nullable-get (SfbAiPollState-result state)))))))))
+          (def (downloadApk
+                 (artifact : SfbAiApkArtifact)) : File
+            (let ((expected
+                    (string-lowercase
+                      (string-trim (SfbAiApkArtifact-sha256 artifact)))))
+              (let ((relative
+                      (sfbAiApkDownloadRelativeValue
+                        (SfbAiApkArtifact-downloadUrl artifact))))
+                (begin
+                  (sfbAiRequire
+                    (sfbAiApkSha256ValidValue expected)
+                    "Invalid APK SHA-256")
+                  (sfbAiRequire
+                    (not (nullable-null? relative))
+                    "Untrusted APK download URL")
+                  (let ((bytes
+                          (sfbAiTransportGetBytesUncached
+                            transport
+                            (nullable-get relative)
+                            (sfbAiMaxApkBytes)
+                            (sfbAiPageConnectTimeout)
+                            (int32 120000))))
+                    (begin
+                      (sfbAiRequire
+                        (equal? (sfbAiSha256Hex bytes) expected)
+                        "APK SHA-256 mismatch")
+                      (sfbAiAtomicWrite
+                        (new File (sfbAiContextCacheDir context) "updates")
+                        (string-append
+                          (string-append "sfb-ssd-review-" expected)
+                          ".apk")
+                        bytes))))))))))))