Complete typed SSD review runtime parity

ober

2afb60eb3313bd87598a22b3cf3a1d9dd877bdf6

diff --git a/templates/ssd-review-parts/GroupSuppression.ss b/templates/ssd-review-parts/GroupSuppression.ss
index 13e8e04..a8a2f13 100644
--- a/templates/ssd-review-parts/GroupSuppression.ss
+++ b/templates/ssd-review-parts/GroupSuppression.ss
@@ -4,7 +4,8 @@
      '((typed-kotlin-file
          "com/sfb/ssdreview/GroupSuppression.kt"
          (typed-library (com sfb ssdreview)
-           (export suppressOverlappingCandidateGroups) (type Float32)
+           (export suppressOverlappingCandidateGroups
+             removeOverlappingCandidateGroupsForTruth) (type Float32)
            (type Int32)
            (def (suppressionMinFloat32 (a : Float32) (b : Float32))
                 :
@@ -110,4 +111,43 @@
                                 reviewedGroup
                                 (list-ref removed i))
                               ignored))
-                          (list-size removed))))))))))
+                          (list-size removed))))))
+           (def (removeOverlappingCandidateGroupsForTruth
+                  (session : SsdSession)
+                  (truthGroup : SsdGroup)) : Int32
+             (let ((truthArea (rectArea (SsdGroup-bbox truthGroup))))
+               (if (<= truthArea (float32 0.0))
+                 (int32 0)
+                 (let ((removed
+                         (for/fold
+                           ((out (mutable-list-empty SsdGroup)))
+                           ((i (in-range
+                                 (int32 0)
+                                 (list-size (SsdSession-groups session)))))
+                           (let ((candidate
+                                   (list-ref
+                                     (SsdSession-groups session)
+                                     i)))
+                             (if (and
+                                   (equal?
+                                     (SsdGroup-status candidate)
+                                     "candidate")
+                                   (>= (suppressionOverlapRatio
+                                         candidate
+                                         truthGroup
+                                         truthArea)
+                                       (float32 0.7)))
+                               (begin
+                                 (mutable-list-add! out candidate)
+                                 out)
+                               out)))))
+                   (begin
+                     (for/fold
+                       ((ignored (int32 0)))
+                       ((i (in-range (int32 0) (list-size removed))))
+                       (begin
+                         (mutable-list-remove!
+                           (SsdSession-groups session)
+                           (list-ref removed i))
+                         ignored))
+                     (list-size removed))))))))))
diff --git a/templates/ssd-review-parts/LearnedFeatures.ss b/templates/ssd-review-parts/LearnedFeatures.ss
index 6d99b1b..21b58e6 100644
--- a/templates/ssd-review-parts/LearnedFeatures.ss
+++ b/templates/ssd-review-parts/LearnedFeatures.ss
@@ -107,7 +107,8 @@
                     (json-object-put-string!
                       out
                       "label"
-                      (json-object-opt-string group "label"))
+                      (string-trim
+                        (json-object-opt-string group "label")))
                     (json-object-put-string!
                       out
                       "box_type_id"
diff --git a/templates/ssd-review-parts/SsdCell.ss b/templates/ssd-review-parts/SsdCell.ss
index 11186c9..1bfd9e9 100644
--- a/templates/ssd-review-parts/SsdCell.ss
+++ b/templates/ssd-review-parts/SsdCell.ss
@@ -128,7 +128,7 @@
                                               minH)
                                           (<= xGap
                                               (maxFloat32
-                                                (float32 4.0)
+                                                (float32 14.0)
                                                 (float32
                                                   (float32-round->int32
                                                     (* minW
@@ -147,7 +147,7 @@
                                             minW)
                                         (<= yGap
                                             (maxFloat32
-                                              (float32 4.0)
+                                              (float32 14.0)
                                               (float32
                                                 (float32-round->int32
                                                   (* minH
diff --git a/templates/ssd-review-parts/TruthApply.ss b/templates/ssd-review-parts/TruthApply.ss
index 3a65830..f793168 100644
--- a/templates/ssd-review-parts/TruthApply.ss
+++ b/templates/ssd-review-parts/TruthApply.ss
@@ -26,13 +26,42 @@
                 (let ([parsed (ssdGroupFromTruthJson groupJson)])
                   (let ([group (pair-first parsed)])
                     (begin
+                      (truthApplyRemoveGroupId!
+                        (SsdSession-groups session)
+                        (SsdGroup-id group))
+                      (truthApplyRemoveGroupId!
+                        (SsdSession-suppressedGroups session)
+                        (SsdGroup-id group))
                       (remapAndAddTruthCells
                         session
                         group
                         (pair-second parsed)
                         usedIds)
-                      (suppressOverlappingCandidateGroups session group)
+                      (removeOverlappingCandidateGroupsForTruth
+                        session
+                        group)
                       (mutable-list-add! target group)))))
+           (def (truthApplyRemoveGroupId!
+                  (groups : (MutableList SsdGroup))
+                  (id : String)) : Unit
+             (let ((matches
+                     (for/fold
+                       ((out (mutable-list-empty SsdGroup)))
+                       ((i (in-range (int32 0) (list-size groups))))
+                       (let ((group (list-ref groups i)))
+                         (if (equal? (SsdGroup-id group) id)
+                           (begin
+                             (mutable-list-add! out group)
+                             out)
+                           out)))))
+               (begin
+                 (for/fold
+                   ((ignored (int32 0)))
+                   ((i (in-range (int32 0) (list-size matches))))
+                   (begin
+                     (mutable-list-remove! groups (list-ref matches i))
+                     ignored))
+                 (begin))))
            (def (applyTruthGroupArrayToSession
                   (session : SsdSession)
                   (groups : JSONArray)
diff --git a/templates/ssd-review-parts/TruthStoreConstants.ss b/templates/ssd-review-parts/TruthStoreConstants.ss
index 3815a27..e72422a 100644
--- a/templates/ssd-review-parts/TruthStoreConstants.ss
+++ b/templates/ssd-review-parts/TruthStoreConstants.ss
@@ -13,23 +13,23 @@
             MAX_AUTH_TOKEN_CHARS)
           (type Int32)
           (val DEFAULT_TRUTH_API_URL : String
-               "https://10.66.60.2:8797/api" (modifiers internal const))
+               "http://127.0.0.1:8797/api" (modifiers internal const))
           (val MAX_HTTP_TEXT_BYTES : Int
                (* (* (int 32) (int 1024)) (int 1024))
                (modifiers internal const))
           (val MAX_HTTP_COMPRESSED_BYTES : Int
-               (* (* (int 2) (int 1024)) (int 1024))
+               (* (* (int 8) (int 1024)) (int 1024))
                (modifiers internal const))
           (val MAX_REMOTE_ZIP_BYTES : Int
                (* (* (int 96) (int 1024)) (int 1024))
                (modifiers internal const))
           (val MAX_ZIP_ENTRY_BYTES : Int
-               (* (* (int 4) (int 1024)) (int 1024))
+               (* (* (int 32) (int 1024)) (int 1024))
                (modifiers internal const))
           (val MAX_ZIP_TOTAL_BYTES : Int
-               (* (* (int 64) (int 1024)) (int 1024))
+               (* (* (int 512) (int 1024)) (int 1024))
                (modifiers internal const))
-          (val MAX_ZIP_ENTRIES : Int32 (int32 512)
+          (val MAX_ZIP_ENTRIES : Int32 (int32 8192)
                (modifiers internal const))
           (val MAX_ZIP_DEPTH : Int32 (int32 8)
                (modifiers internal const))
@@ -40,11 +40,11 @@
           (val RATIO_FLOOR_BYTES : Int (* (int 64) (int 1024))
                (modifiers internal const))
           (val MAX_STORAGE_BYTES : Int
-               (* (* (int 128) (int 1024)) (int 1024))
+               (* (* (int 512) (int 1024)) (int 1024))
                (modifiers internal const))
           (val MAX_STORAGE_FILES : Int32 (int32 16384)
                (modifiers internal const))
-          (val MAX_LEARNED_EXAMPLES : Int32 (int32 10000)
+          (val MAX_LEARNED_EXAMPLES : Int32 (int32 50000)
                (modifiers internal const))
           (val MAX_JSONL_LINES : Int32 (int32 51200)
                (modifiers internal const))
diff --git a/templates/ssd-review-parts/TruthStoreJson.ss b/templates/ssd-review-parts/TruthStoreJson.ss
index bc7f149..f4932d1 100644
--- a/templates/ssd-review-parts/TruthStoreJson.ss
+++ b/templates/ssd-review-parts/TruthStoreJson.ss
@@ -35,7 +35,11 @@
                                 (json-object-opt-json-array
                                   truth
                                   "groups"))])
-                  (begin
+                  (if (string-starts-with?
+                        (json-object-opt-string truth "source_key")
+                        "ssd-selftest-")
+                    (begin)
+                    (begin
                     (for/fold
                       ((ignored (int32 0)))
                       ((i (in-range (int32 0) (json-array-length groups))))
@@ -46,15 +50,30 @@
                             (begin
                               (if (nullable-null? group)
                                   (begin)
-                                  (json-array-put-json-object!
-                                    examples
-                                    (learnedExampleFromTruthGroup
-                                      truth
-                                      truthFileName
-                                      (nullable-get group))))
+                                  (let ((value (nullable-get group)))
+                                    (let ((status
+                                            (json-object-opt-string
+                                              value "status"))
+                                          (label
+                                            (string-trim
+                                              (json-object-opt-string
+                                                value "label"))))
+                                      (if (and
+                                            (or (equal? status "reviewed")
+                                                (equal? status "approved"))
+                                            (and (not (string-blank? label))
+                                                 (not (string-ends-with?
+                                                        label "?"))))
+                                        (json-array-put-json-object!
+                                          examples
+                                          (learnedExampleFromTruthGroup
+                                            truth
+                                            truthFileName
+                                            value))
+                                        (begin)))))
                               ignored))
                           ignored))
-                    (begin))))
+                    (begin)))))
            (def (learnedExamplesDocument
                   (examples : JSONArray)
                   (maxExamples : Int32))
diff --git a/templates/ssd-review-parts/UiTextHelpers.ss b/templates/ssd-review-parts/UiTextHelpers.ss
index 051fc00..f43eff4 100644
--- a/templates/ssd-review-parts/UiTextHelpers.ss
+++ b/templates/ssd-review-parts/UiTextHelpers.ss
@@ -3,7 +3,7 @@
 (def fragment
      '((typed-kotlin-file
          "com/sfb/ssdreview/UiTextHelpers.kt"
-         (kotlin-imports (java io File) (java io InputStream)
+         (kotlin-imports (java io File) (java io FileDescriptor) (java io InputStream)
           (java io OutputStream) (java lang Runnable)
           (java lang Thread) (java util UUID) (kotlin io FileTreeWalk)
           (android Manifest) (android app AlertDialog)
@@ -255,7 +255,7 @@
           (type Button) (type ButtonKind) (type CharSequence)
           (type ContentResolver) (type Context) (type DialogInterface)
           (type DocumentsContract) (type Editable) (type Editor)
-          (type EditText) (type File) (type FileTreeWalk)
+          (type EditText) (type File) (type FileDescriptor) (type FileTreeWalk)
           (type SharedPreferences) (type InputStream)
           (type OutputStream) (type LayoutParams) (type Cursor)
           (type Intent) (type Manifest) (type Uri)
@@ -415,6 +415,23 @@
             (Nullable ParcelFileDescriptor)
             (kotlin-member-call openFileDescriptor))
           (extern
+            (parcelFileDescriptorFileRaw
+              (descriptor : ParcelFileDescriptor))
+            :
+            FileDescriptor
+            (kotlin-member-get fileDescriptor))
+          (extern
+            (fileDescriptorValidRaw (descriptor : FileDescriptor))
+            :
+            Bool
+            (kotlin-member-call valid))
+          (extern
+            (parcelFileDescriptorCloseRaw
+              (descriptor : ParcelFileDescriptor))
+            :
+            Unit
+            (kotlin-member-call close))
+          (extern
             (sha1HexRaw (input : InputStream))
             :
             String
@@ -2840,6 +2857,7 @@
                                 "Approved on Android")])
                    (begin
                      (mutable-list-add! (SsdSession-groups session) group)
+                     (suppressOverlappingCandidateGroups session group)
                      group))))
           (def (ssdSessionApplyEditedGroup (session : SsdSession) (group : SsdGroup)
                  (labelText : String) (resolvedName : String)
@@ -2862,7 +2880,9 @@
                    group
                    (editedGroupCountText countText (SsdGroup-count group)))
                  (SsdGroup-status-set! group "reviewed")
-                 (ssdSessionRecomputeGroup session group)))
+                 (ssdSessionRecomputeGroup session group)
+                 (suppressOverlappingCandidateGroups session group)
+                 (begin)))
           (def (selectionIgnoredLoadingStatus)
                :
                String
@@ -3456,6 +3476,9 @@
                                (ssdSessionApproveGroup
                                  loadedSession
                                  approvedGroup)
+                               (suppressOverlappingCandidateGroups
+                                 loadedSession
+                                 approvedGroup)
                                (truthStoreAppendGroupApproved
                                  store
                                  loadedSession)
@@ -4067,12 +4090,15 @@
                (truthStoreSeedBundledTruthAsync
                  store
                  (lambda ((imported : Int32))
-                   (if (positiveCount imported)
+                   (if (mainActivitySelfTestEnabledRaw activity)
                        (mainActivityRunOnUiThreadRaw
                          activity
                          (lambda ()
-                           (if (mainActivitySelfTestEnabledRaw activity)
-                               (mainActivitySelfTestLogRaw activity "seed-merged")
+                           (mainActivitySelfTestLogRaw activity "seed-merged")))
+                       (if (positiveCount imported)
+                           (mainActivityRunOnUiThreadRaw
+                             activity
+                             (lambda ()
                                (begin
                                  (mainActivitySetStatusRaw
                                    activity
@@ -4082,8 +4108,8 @@
                                  (if (pdfUriLoaded
                                        (mainActivityPdfUriRaw activity))
                                      (mainActivityRenderCurrentPage activity)
-                                     (begin))))))
-                       (begin)))))
+                                     (begin)))))
+                           (begin))))))
           (def (mainActivityHandleAreaSelected (activity : MainActivity) (view : SsdReviewView)
                  (store : TruthStore) (rect : FloatArray)
                  (detectedIds : (List String)) (confirmHaptic : Int32))
@@ -4994,7 +5020,30 @@
                      (afterTextChanged (s : (Nullable Editable)))
                      :
                      Unit
-                     (begin)))))
+                     (if (autoCompleteExactBoxTypeDisplay?
+                           activity
+                           boxType)
+                       (autoCompleteDismissDropDownRaw boxType)
+                       (begin))))))
+          (def (autoCompleteExactBoxTypeDisplay?
+                 (activity : MainActivity)
+                 (boxType : AutoCompleteTextView)) : Bool
+            (let ((text
+                    (string-lowercase
+                      (string-trim (autoCompleteText boxType))))
+                  (resolved
+                    (boxTypesResolveFromActivity
+                      activity
+                      (autoCompleteText boxType))))
+              (if (nullable-null? resolved)
+                #f
+                (equal?
+                  text
+                  (string-lowercase
+                    (string-trim
+                      (boxTypesDisplayFromActivity
+                        activity
+                        (BoxType-id (nullable-get resolved)))))))))
           (def (editTextText (view : EditText))
                :
                String
@@ -5544,6 +5593,74 @@
                      index
                      (mainActivityDpiValue))
                    (pdfRendererCloseRaw renderer))))
+          (def (mainActivityCanOpenPdfUri
+                 (activity : MainActivity)
+                 (uri : Uri))
+               :
+               Bool
+               (try
+                 (let ([descriptor
+                         (contentResolverOpenFileDescriptorRaw
+                           (mainActivityContentResolverRaw activity)
+                           uri
+                           "r")])
+                   (if (nullable-null? descriptor)
+                       #f
+                       (let ([opened (nullable-get descriptor)])
+                         (try-finally
+                           (fileDescriptorValidRaw
+                             (parcelFileDescriptorFileRaw opened))
+                           (parcelFileDescriptorCloseRaw opened)))))
+                 (catch (error : Exception) #f)))
+          (def (mainActivityClearLastPdfState
+                 (activity : MainActivity))
+               :
+               Unit
+               (begin
+                 (mainActivityPdfUriSet
+                   activity
+                   (nullable-none Uri))
+                 (mainActivityPdfNameSet activity "")
+                 (mainActivityPdfSha1Set activity "")
+                 (mainActivityPageIndexSet activity (int32 0))
+                 (mainActivityPageCountSet activity (int32 0))
+                 (sharedPreferencesEditorApplyRaw
+                   (sharedPreferencesEditorRemoveSavedViewportRaw
+                     (sharedPreferencesEditorRemoveRaw
+                       (sharedPreferencesEditorRemoveRaw
+                         (sharedPreferencesEditorRemoveRaw
+                           (sharedPreferencesEditorRemoveRaw
+                             (sharedPreferencesEditorRemoveRaw
+                               (sharedPreferencesEditRaw
+                                 (mainActivityGetPreferencesRaw
+                                   activity
+                                   (contextModePrivate)))
+                               (mainActivityPrefLastPdfUriValue))
+                             (mainActivityPrefLastPdfNameValue))
+                           (mainActivityPrefLastPdfSha1Value))
+                         (mainActivityPrefLastPageIndexValue))
+                       (mainActivityPrefLastPageCountValue))))))
+          (def (mainActivityStalePdfFields (uri : Uri)) : JSONObject
+            (let ([fields (emptyJsonObject)])
+              (begin
+                (json-object-put-string!
+                  fields "uri" (uriToStringRaw uri))
+                fields)))
+          (def (mainActivityRejectStalePdf
+                 (activity : MainActivity)
+                 (uri : Uri))
+               :
+               Bool
+               (begin
+                 (mainActivityClearLastPdfState activity)
+                 (mainActivitySetStatusRaw
+                   activity
+                   "Previous PDF is no longer available. Open an SSD PDF.")
+                 (mainActivityLogClientEvent
+                   activity
+                   "ssd-last-pdf-stale"
+                   (mainActivityStalePdfFields uri))
+                 #f))
           (def (mainActivityRestoreLastPdf (activity : MainActivity))
                :
                Bool
@@ -5564,12 +5681,19 @@
                                 (if (mainActivityServerUriRaw uri)
                                     (mainActivityRestoreServerPdfRaw
                                       activity uri page)
-                                    (begin
-                                      (mainActivityLoadPdf activity uri
-                                        (intentFlagGrantReadUriPermission) page
-                                        #t)
-                                      #t)))
-                              (catch (error : Exception) #f)))))))
+                                    (if (mainActivityCanOpenPdfUri activity uri)
+                                        (begin
+                                          (mainActivityLoadPdf activity uri
+                                            (intentFlagGrantReadUriPermission) page
+                                            #t)
+                                          #t)
+                                        (mainActivityRejectStalePdf
+                                          activity uri))))
+                              (catch (error : Exception)
+                                (mainActivityRejectStalePdf
+                                  activity
+                                  (uriParse
+                                    (nullableTextOrEmpty raw))))))))))
           (def (sharedPreferencesEditorRemoveSavedViewportRaw
                  (editor : Editor))
                :
@@ -7005,13 +7129,11 @@
                                (string-append
                                  (detectionTruthNoteFor existingTruth)
                                  (string-append
-                                   (if forceDetect
-                                       ""
-                                       (detectionOcrNote
-                                         (DetectionPreparation-ocrGuesses
-                                           preparation)
-                                         (list-size
-                                           (SsdSession-ocrWords session))))
+                                   (detectionOcrNote
+                                     (DetectionPreparation-ocrGuesses
+                                       preparation)
+                                     (list-size
+                                       (SsdSession-ocrWords session)))
                                    (detectionLearnedNote
                                      learnedGuesses))))))
                          (mainActivityLogDetectResultRaw activity "page-load" session
@@ -7445,13 +7567,11 @@
                      (string-append
                        (int32->string ocrGuesses)
                        " OCR guesses"))
-                   (if (> ocrWordCount (int32 0))
-                       (string-append
-                         ", OCR "
-                         (string-append
-                           (int32->string ocrWordCount)
-                           " words"))
-                       "")))
+                   (string-append
+                     ", OCR "
+                     (string-append
+                       (int32->string ocrWordCount)
+                       " words"))))
           (def (detectionLearnedNote (learnedGuesses : Int32))
                :
                String
diff --git a/templates/ssd-review-parts/main-ai-flow.ss b/templates/ssd-review-parts/main-ai-flow.ss
index ca5223f..c9f3ed5 100644
--- a/templates/ssd-review-parts/main-ai-flow.ss
+++ b/templates/ssd-review-parts/main-ai-flow.ss
@@ -263,6 +263,12 @@
         (extern (mainAiResultApk
                   (result : SfbAiReportResult)) : (Nullable SfbAiApkArtifact)
           (kotlin-member-get apk))
+        (extern (mainAiArtifactSha256
+                  (artifact : SfbAiApkArtifact)) : String
+          (kotlin-member-get sha256))
+        (extern (mainAiArtifactUploaded
+                  (artifact : SfbAiApkArtifact)) : Bool
+          (kotlin-member-get uploadedToTermux))
         (extern (mainAiDownloadReady
                   (result : SfbAiReportResult)
                   (allowDryRun : Bool)) : Bool
@@ -303,6 +309,9 @@
         (extern (mainAiCacheDir
                   (activity : MainActivity)) : File
           (kotlin-member-get cacheDir))
+        (extern (mainAiFilesDir
+                  (activity : MainActivity)) : File
+          (kotlin-member-get filesDir))
         (extern (mainAiFile
                   (parent : File)
                   (leaf : String)) : File
@@ -315,6 +324,12 @@
           (kotlin-member-get parentFile))
         (extern (mainAiFileIsFile (file : File)) : Bool
           (kotlin-member-get isFile))
+        (extern (mainAiFileReadText (file : File)) : String
+          (kotlin-member-call readText))
+        (extern (mainAiStringTakeLast
+                  (text : String)
+                  (count : Int32)) : String
+          (kotlin-member-call takeLast))
         (extern (mainAiFilePath (file : File)) : String
           (kotlin-member-get absolutePath))
         (extern (mainAiPackageManager
@@ -449,7 +464,21 @@
             (+ (mainAiPage activity) (int32 1))
             (mainAiPageCount activity)
             (mainAiSelectedGroupText activity)
-            ""))
+            (mainAiRecentTelemetry activity)))
+        (def (mainAiRecentTelemetry
+               (activity : MainActivity)) : String
+          (try
+            (let ((file
+                    (mainAiFile
+                      (mainAiFilesDir activity)
+                      "ssd-review-client-local.jsonl")))
+              (if (mainAiFileIsFile file)
+                (let ((content (mainAiFileReadText file)))
+                  (if (<= (string-length content) 262144)
+                    content
+                    (mainAiStringTakeLast content (int32 262144))))
+                ""))
+            (catch (error : Exception) "")))
         (def (mainAiSubmissionFields
                (submission : SfbAiReportSubmission)
                (screenshotBytes : Int32)) : JSONObject
@@ -462,6 +491,35 @@
               (json-object-put-int32!
                 fields "screenshot_bytes" screenshotBytes)
               fields)))
+        (def (mainAiResultFields
+               (jobId : String)
+               (result : SfbAiReportResult)
+               (downloaded : (Nullable File))) : JSONObject
+          (let ((fields (json-object-empty))
+                (artifact (mainAiResultApk result)))
+            (begin
+              (json-object-put-string! fields "job_id" jobId)
+              (json-object-put-string!
+                fields "state" (mainAiResultState result))
+              (json-object-put-int32!
+                fields
+                "feedback_chars"
+                (int32 (string-length (mainAiResultFeedback result))))
+              (json-object-put-bool!
+                fields
+                "uploaded_to_termux"
+                (if (nullable-null? artifact)
+                  #f
+                  (mainAiArtifactUploaded (nullable-get artifact))))
+              (json-object-put-bool!
+                fields "apk_ready" (not (nullable-null? downloaded)))
+              (json-object-put-string!
+                fields
+                "sha256"
+                (if (nullable-null? artifact)
+                  ""
+                  (mainAiArtifactSha256 (nullable-get artifact))))
+              fields)))
         (def (mainAiResultText
                (result : SfbAiReportResult)
                (apk : (Nullable File))
@@ -634,14 +692,20 @@
                                             (catch (error : Exception)
                                               (nullable-none File)))
                                           (nullable-none File))))
-                                  (mainAiRunOnUi
-                                    activity
-                                    (lambda ()
-                                      (mainAiShowResult
-                                        activity
-                                        resolved
-                                        downloaded
-                                        "")))))))))
+                                  (begin
+                                    (mainAiLogEvent
+                                      activity
+                                      "ssd-ai-report-result"
+                                      (mainAiResultFields
+                                        jobId resolved downloaded))
+                                    (mainAiRunOnUi
+                                      activity
+                                      (lambda ()
+                                        (mainAiShowResult
+                                          activity
+                                          resolved
+                                          downloaded
+                                          ""))))))))))
                       (catch (error : Exception)
                         (mainAiRunOnUi
                           activity
@@ -724,7 +788,7 @@
                           (mainAiBuilderTitle
                             (mainAiBuilder activity)
                             "Send Display to AI")
-                          "The current display and app context will be sent with your description.")
+                          "The current display and recent app telemetry will be sent with your description.")
                         input)
                       "Cancel"
                       (lambda ((unused : DialogInterface) (which : Int32))
diff --git a/templates/ssd-review-parts/main-server-detect.ss b/templates/ssd-review-parts/main-server-detect.ss
index f87bdf0..dab9df8 100644
--- a/templates/ssd-review-parts/main-server-detect.ss
+++ b/templates/ssd-review-parts/main-server-detect.ss
@@ -80,6 +80,11 @@
                   (client : SfbServerClient)
                   (name : String)) : (Nullable SfbServerPdf)
           (kotlin-member-call findServerPdfByName))
+        (extern (mainServerDetectEnsureReachable
+                  (client : SfbServerClient)
+                  (allowLoopback : Bool)) : SfbServerInfo
+          (kotlin-member-call ensureReachableServerEndpoint))
+        (type SfbServerInfo)
         (extern (mainServerDetectMatchedPdfId
                   (pdf : SfbServerPdf)) : String
           (kotlin-member-get pdfId))
@@ -331,7 +336,9 @@
               (lambda ()
                 (try
                   (let ((client (mainServerDetectClient activity)))
-                    (let ((pdfId
+                    (begin
+                      (mainServerDetectEnsureReachable client #t)
+                      (let ((pdfId
                             (mainServerDetectResolvePdfId
                               activity client name)))
                       (if (string-blank? pdfId)
@@ -363,7 +370,7 @@
                                   (lambda ()
                                     (mainServerApplyLocalLabels
                                       activity token generation page uri
-                                      session))))))))))
+                                      session)))))))))))
                   (catch (error : Exception)
                     (mainServerDetectRunOnUi
                       activity
@@ -407,7 +414,9 @@
                     (lambda ()
                       (try
                         (let ((client (mainServerDetectClient activity)))
-                          (let ((pdfId
+                          (begin
+                            (mainServerDetectEnsureReachable client #t)
+                            (let ((pdfId
                                   (mainServerDetectResolvePdfId
                                     activity client name)))
                             (if (string-blank? pdfId)
@@ -436,7 +445,7 @@
                                       (lambda ()
                                         (mainServerDetectApply
                                           activity generation page loadedUri
-                                          session)))))))))
+                                          session))))))))))
                         (catch (error : Exception)
                           (begin
                             (mainServerDetectLogEvent
diff --git a/templates/ssd-review-parts/main-server-lifecycle.ss b/templates/ssd-review-parts/main-server-lifecycle.ss
index ca6791b..1064947 100644
--- a/templates/ssd-review-parts/main-server-lifecycle.ss
+++ b/templates/ssd-review-parts/main-server-lifecycle.ss
@@ -70,9 +70,10 @@
                   (client : SfbServerClient)
                   (endpoint : String)) : Unit
           (kotlin-member-call setEndpoint))
-        (extern (mainServerClientRefresh
-                  (client : SfbServerClient)) : SfbServerInfo
-          (kotlin-member-call refreshServerEndpoint))
+        (extern (mainServerClientEnsureReachable
+                  (client : SfbServerClient)
+                  (allowLoopback : Bool)) : SfbServerInfo
+          (kotlin-member-call ensureReachableServerEndpoint))
         (extern (mainServerClientIsLoopback
                   (client : SfbServerClient)) : Bool
           (kotlin-member-call endpointIsLoopback))
@@ -183,7 +184,7 @@
               (try
                 (let ((client (mainServerClientRaw activity)))
                   (begin
-                    (mainServerClientRefresh client)
+                    (mainServerClientEnsureReachable client allowLoopback)
                     (let ((endpoint (mainServerClientEndpoint client)))
                       (begin
                         (if (or allowLoopback
diff --git a/templates/ssd-review-parts/main-server-pages.ss b/templates/ssd-review-parts/main-server-pages.ss
index 06680db..5c9af9f 100644
--- a/templates/ssd-review-parts/main-server-pages.ss
+++ b/templates/ssd-review-parts/main-server-pages.ss
@@ -217,9 +217,10 @@
         (extern (mainServerPageClientCatalog
                   (client : SfbServerClient)) : SfbServerCatalog
           (kotlin-member-call fetchServerPdfs))
-        (extern (mainServerPageClientRefresh
-                  (client : SfbServerClient)) : SfbServerInfo
-          (kotlin-member-call refreshServerEndpoint))
+        (extern (mainServerPageClientEnsureReachable
+                  (client : SfbServerClient)
+                  (allowLoopback : Bool)) : SfbServerInfo
+          (kotlin-member-call ensureReachableServerEndpoint))
         (extern (mainServerPageClientEndpoint
                   (client : SfbServerClient)) : String
           (kotlin-member-call endpointLabel))
@@ -560,7 +561,7 @@
                   (try
                     (let ((client (mainServerPageClient activity)))
                       (begin
-                        (mainServerPageClientRefresh client)
+                        (mainServerPageClientEnsureReachable client #f)
                         (mainServerPageRunOnUi
                           activity
                           (lambda ()
diff --git a/templates/ssd-review-parts/server-client.ss b/templates/ssd-review-parts/server-client.ss
index 2cbc422..cff7a32 100644
--- a/templates/ssd-review-parts/server-client.ss
+++ b/templates/ssd-review-parts/server-client.ss
@@ -3,14 +3,27 @@
 (def fragment
   '((typed-kotlin-file "com/sfb/ssdreview/SfbServerClient.kt"
       (kotlin-imports (android content Context)
+                      (android net ConnectivityManager)
+                      (android net LinkAddress)
+                      (android net LinkProperties)
+                      (android net Network)
+                      (java lang Runnable)
+                      (java lang Thread)
+                      (java net InetAddress)
                       (org json JSONObject))
       (typed-library (com sfb ssdreview)
         (export SfbServerClient sfbLanEndpointTexts)
         (type Context)
+        (type ConnectivityManager)
         (type Exception)
+        (type InetAddress)
         (type IllegalStateException)
         (type Int32)
+        (type LinkAddress)
+        (type LinkProperties)
+        (type Network)
         (type JSONObject)
+        (type Runnable)
         (type SfbEndpoint)
         (type SfbEndpointProbe)
         (type SfbHttpTransport)
@@ -22,6 +35,7 @@
         (type SfbServerPdf)
         (type SfbServerRecognition)
         (type SfbServerSnapshot)
+        (type Thread)
         (extern (sfbClientIllegalStateException
                   (message : String)) : IllegalStateException
           (kotlin-call IllegalStateException))
@@ -114,6 +128,51 @@
           (kotlin-value SFB_MAX_SNAPSHOT_BYTES))
         (extern (sfbClientNowMs) : Int
           (kotlin-call java lang System currentTimeMillis))
+        (extern (sfbClientConnectivityServiceName) : String
+          (kotlin-value Context CONNECTIVITY_SERVICE))
+        (extern (sfbClientSystemService
+                  (context : Context)
+                  (name : String)) : Any
+          (kotlin-member-call getSystemService))
+        (extern (sfbClientAsConnectivityManager
+                  (service : Any)) : ConnectivityManager
+          (kotlin-cast ConnectivityManager))
+        (extern (sfbClientNetworks
+                  (manager : ConnectivityManager)) : (Array Network)
+          (kotlin-member-get allNetworks))
+        (extern (sfbClientNetworkCount
+                  (networks : (Array Network))) : Int32
+          (kotlin-member-get size))
+        (extern (sfbClientNetworkAt
+                  (networks : (Array Network))
+                  (index : Int32)) : Network
+          (kotlin-member-call get))
+        (extern (sfbClientLinkProperties
+                  (manager : ConnectivityManager)
+                  (network : Network)) : (Nullable LinkProperties)
+          (kotlin-member-call getLinkProperties))
+        (extern (sfbClientLinkAddresses
+                  (properties : LinkProperties)) : (List LinkAddress)
+          (kotlin-member-get linkAddresses))
+        (extern (sfbClientLinkAddress
+                  (link : LinkAddress)) : InetAddress
+          (kotlin-member-get address))
+        (extern (sfbClientAddressLoopback
+                  (address : InetAddress)) : Bool
+          (kotlin-member-get isLoopbackAddress))
+        (extern (sfbClientAddressHost
+                  (address : InetAddress)) : (Nullable String)
+          (kotlin-member-get hostAddress))
+        (extern (sfbClientThread
+                  (runnable : Runnable)
+                  (name : String)) : Thread
+          (kotlin-call Thread))
+        (extern (sfbClientThreadStart (thread : Thread)) : Unit
+          (kotlin-member-call start))
+        (extern (sfbClientThreadJoin
+                  (thread : Thread)
+                  (timeout : Int)) : Unit
+          (kotlin-member-call join))
         (extern (sfbTransportCurrentEndpoint
                   (transport : SfbHttpTransport)) : SfbEndpoint
           (kotlin-member-call currentEndpoint))
@@ -237,8 +296,120 @@
                           ":8797/api")))
                     same))
                 out))))
+        (def (sfbClientPrivateIpv4 (host : String)) : Bool
+          (string-matches-regex?
+            host
+            "^(10\\.[0-9]+\\.[0-9]+\\.[0-9]+|192\\.168\\.[0-9]+\\.[0-9]+|172\\.(1[6-9]|2[0-9]|3[01])\\.[0-9]+\\.[0-9]+)$"))
+        (def (sfbClientPrefixListed
+               (prefixes : (List String))
+               (prefix : String)) : Bool
+          (for/fold ((found #f))
+                    ((i (in-range (int32 0) (list-size prefixes))))
+            (or found (equal? (list-ref prefixes i) prefix))))
+        (def (sfbClientAddAddressPrefix
+               (out : (MutableList String))
+               (address : InetAddress)) : Unit
+          (if (sfbClientAddressLoopback address)
+            (begin)
+            (let ((host (sfbClientAddressHost address)))
+              (if (nullable-null? host)
+                (begin)
+                (let ((text (nullable-get host)))
+                  (if (sfbClientPrivateIpv4 text)
+                    (let ((prefix
+                            (string-replace-regex
+                              text "\\.[0-9]+$" "")))
+                      (if (sfbClientPrefixListed out prefix)
+                        (begin)
+                        (mutable-list-add! out prefix)))
+                    (begin)))))))
+        (def (sfbLocalIpv4Subnets
+               (context : Context)) : (MutableList String)
+          (let ((out (mutable-list-empty String))
+                (manager
+                  (sfbClientAsConnectivityManager
+                    (sfbClientSystemService
+                      context (sfbClientConnectivityServiceName)))))
+            (let ((networks (sfbClientNetworks manager)))
+              (begin
+                (for/fold ((same out))
+                          ((i (in-range
+                                (int32 0)
+                                (sfbClientNetworkCount networks))))
+                  (let ((properties
+                          (sfbClientLinkProperties
+                            manager (sfbClientNetworkAt networks i))))
+                    (if (nullable-null? properties)
+                      same
+                      (let ((addresses
+                              (sfbClientLinkAddresses
+                                (nullable-get properties))))
+                        (begin
+                          (for/fold ((ignored (int32 0)))
+                                    ((j (in-range
+                                          (int32 0)
+                                          (list-size addresses))))
+                            (begin
+                              (sfbClientAddAddressPrefix
+                                out
+                                (sfbClientLinkAddress
+                                  (list-ref addresses j)))
+                              ignored))
+                          same)))))
+                out))))
+        (record SfbDiscoveryState
+          ((mut winner : (Nullable SfbEndpoint))))
+        (def (sfbProbeStrideUntilFound
+               (transport : SfbHttpTransport)
+               (endpoints : (List SfbEndpoint))
+               (state : SfbDiscoveryState)
+               (index : Int32)
+               (stride : Int32)) : Unit
+          (if (or (>= index (list-size endpoints))
+                  (not (nullable-null?
+                         (SfbDiscoveryState-winner state))))
+            (begin)
+            (let ((endpoint (list-ref endpoints index)))
+              (if (sfbClientProbeReachable
+                    (sfbProbeEndpoint transport endpoint))
+                (SfbDiscoveryState-winner-set!
+                  state (nullable-some endpoint))
+                (sfbProbeStrideUntilFound
+                  transport endpoints state (+ index stride) stride)))))
+        (def (sfbParallelFirstReachable
+               (transport : SfbHttpTransport)
+               (endpoints : (List SfbEndpoint))) : (Nullable SfbEndpoint)
+          (let ((state
+                  (make-SfbDiscoveryState
+                    (nullable-none SfbEndpoint)))
+                (threads (mutable-list-empty Thread))
+                (workerCount
+                  (if (< (list-size endpoints) (int32 48))
+                    (list-size endpoints)
+                    (int32 48))))
+            (begin
+              (for/fold ((same threads))
+                        ((i (in-range (int32 0) workerCount)))
+                (let ((thread
+                        (sfbClientThread
+                          (object Runnable
+                            (override (run) : Unit
+                              (sfbProbeStrideUntilFound
+                                transport endpoints state i workerCount)))
+                          (string-append
+                            "sfb-server-probe-" (int32->string i)))))
+                  (begin
+                    (mutable-list-add! threads thread)
+                    (sfbClientThreadStart thread)
+                    same)))
+              (for/fold ((ignored (int32 0)))
+                        ((i (in-range (int32 0) (list-size threads))))
+                (begin
+                  (sfbClientThreadJoin (list-ref threads i) (int 4000))
+                  ignored))
+              (SfbDiscoveryState-winner state))))
         (class SfbServerClient
-          ((context : Context)
+          ((context : Context (property val) (visibility private))
            (endpointText : String)
            (bearerToken : String (property val) (visibility private)))
           (val transport : SfbHttpTransport
@@ -265,7 +436,7 @@
                  (candidateUrls : (List String))) : SfbServerInfo
             (let ((candidates
                     (sfbEndpointTextsToEndpoints candidateUrls bearerToken)))
-              (let ((best (selectFastest candidates)))
+              (let ((best (sfbParallelFirstReachable transport candidates)))
                 (begin
                   (sfbClientRequire
                     (not (nullable-null? best))
@@ -303,10 +474,13 @@
                 (if (and (sfbClientProbeReachable probe)
                          (or allowLoopback (not (sfbClientEndpointLoopback current))))
                   (fetchServerInfoUncached)
-                  (throw
-                    (sfbClientIllegalStateException
-                      "Configured sfb-server endpoint is not reachable")
-                    SfbServerInfo)))))
+                  (let ((prefixes (sfbLocalIpv4Subnets context)))
+                    (begin
+                      (sfbClientRequire
+                        (> (list-size prefixes) (int32 0))
+                        "No local IPv4 subnet is available")
+                      (discoverServerEndpoint
+                        (sfbLanEndpointTexts prefixes))))))))
           (def (fetchServerInfoUncached) : SfbServerInfo
             (sfbClientInfoFromJson
               (sfbTransportGetJsonUncached