Add typed SSD AI report update client
ober
c0173c76a0043cfa7f268b44794787540989fdd7
new file mode 100644 --- /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))))))))))))