Split typed SSD client into per-file fragments

ober

c41ef257fc3983961a5f92693f149fc59fe2d6e0

diff --git a/templates/ssd-review-parts/AndroidApi.ss b/templates/ssd-review-parts/AndroidApi.ss
new file mode 100644
index 0000000..e03dc0e
--- /dev/null
+++ b/templates/ssd-review-parts/AndroidApi.ss
@@ -0,0 +1,15 @@
+(import (jerboa prelude))
+
+(def fragment
+     '((typed-kotlin-file
+         "com/sfb/ssdreview/AndroidApi.kt"
+         (typed-library (com sfb ssdreview) (export sdkAtLeast sdkBefore)
+           (type Int32)
+           (def (sdkAtLeast (sdk : Int32) (api : Int32))
+                :
+                Bool
+                (>= sdk api))
+           (def (sdkBefore (sdk : Int32) (api : Int32))
+                :
+                Bool
+                (< sdk api))))))
diff --git a/templates/ssd-review-parts/AndroidBitmap.ss b/templates/ssd-review-parts/AndroidBitmap.ss
new file mode 100644
index 0000000..13aad1e
--- /dev/null
+++ b/templates/ssd-review-parts/AndroidBitmap.ss
@@ -0,0 +1,74 @@
+(import (jerboa prelude))
+
+(def fragment
+     '((typed-kotlin-file
+         "com/sfb/ssdreview/AndroidBitmap.kt"
+         (kotlin-imports (android graphics Bitmap))
+         (typed-library (com sfb ssdreview)
+           (export bitmapPixelWidth bitmapPixelHeight bitmapHasPixels
+             bitmapReadyForNativeDetection
+             bitmapNotReadyForNativeDetection bitmapPixels
+             bitmapWindowPixels bitmapToRgba)
+           (type Bitmap) (type Int32) (type IntArray) (type Bytes)
+           (extern
+             (bitmapWidth (bitmap : Bitmap))
+             :
+             Int32
+             (kotlin-member-get width))
+           (extern
+             (bitmapHeight (bitmap : Bitmap))
+             :
+             Int32
+             (kotlin-member-get height))
+           (extern
+             (bitmapGetPixels (bitmap : Bitmap) (pixels : IntArray) (offset : Int32)
+               (stride : Int32) (x : Int32) (y : Int32) (width : Int32)
+               (height : Int32))
+             :
+             Unit
+             (kotlin-member-call getPixels))
+           (def (bitmapPixelWidth (bitmap : Bitmap))
+                :
+                Int32
+                (bitmapWidth bitmap))
+           (def (bitmapPixelHeight (bitmap : Bitmap))
+                :
+                Int32
+                (bitmapHeight bitmap))
+           (def (bitmapHasPixels (bitmap : Bitmap))
+                :
+                Bool
+                (and (> (bitmapPixelWidth bitmap) (int32 0))
+                     (> (bitmapPixelHeight bitmap) (int32 0))))
+           (def (bitmapReadyForNativeDetection
+                  (loaded : Bool)
+                  (bitmap : Bitmap))
+                :
+                Bool
+                (and loaded (bitmapHasPixels bitmap)))
+           (def (bitmapNotReadyForNativeDetection
+                  (loaded : Bool)
+                  (bitmap : Bitmap))
+                :
+                Bool
+                (not (bitmapReadyForNativeDetection loaded bitmap)))
+           (def (bitmapWindowPixels (bitmap : Bitmap) (x : Int32)
+                  (y : Int32) (width : Int32) (height : Int32))
+                :
+                IntArray
+                (let ([pixels (int-array-build
+                                (* width height)
+                                (index (int32 0)))])
+                  (begin
+                    (bitmapGetPixels bitmap pixels (int32 0) width x y
+                      width height)
+                    pixels)))
+           (def (bitmapPixels (bitmap : Bitmap))
+                :
+                IntArray
+                (bitmapWindowPixels bitmap (int32 0) (int32 0)
+                  (bitmapPixelWidth bitmap) (bitmapPixelHeight bitmap)))
+           (def (bitmapToRgba (bitmap : Bitmap))
+                :
+                Bytes
+                (pixelsToRgba (bitmapPixels bitmap)))))))
diff --git a/templates/ssd-review-parts/AppendNote.ss b/templates/ssd-review-parts/AppendNote.ss
new file mode 100644
index 0000000..c4c172c
--- /dev/null
+++ b/templates/ssd-review-parts/AppendNote.ss
@@ -0,0 +1,16 @@
+(import (jerboa prelude))
+
+(def fragment
+     '((typed-kotlin-file
+         "com/sfb/ssdreview/AppendNote.kt"
+         (typed-library
+           (com sfb ssdreview)
+           (export appendNote)
+           (def (appendNote (existing : String) (note : String))
+                :
+                String
+                (if (string-blank? existing)
+                    note
+                    (string-append
+                      existing
+                      (string-append "; " note))))))))
diff --git a/templates/ssd-review-parts/BoxType.ss b/templates/ssd-review-parts/BoxType.ss
new file mode 100644
index 0000000..84d9b34
--- /dev/null
+++ b/templates/ssd-review-parts/BoxType.ss
@@ -0,0 +1,57 @@
+(import (jerboa prelude))
+
+(def fragment
+     '((typed-kotlin-file
+         "com/sfb/ssdreview/BoxType.kt"
+         (typed-library (com sfb ssdreview)
+           (export make-BoxType BoxType? BoxType-id BoxType-name
+             box-type-display boxTypeDisplays boxTypeById
+             boxTypeDisplayById addNullableBoxType)
+           (type Int) (type Int32)
+           (record BoxType ((id : String) (name : String)))
+           (def (box-type-display (box : BoxType))
+                :
+                String
+                (string-append
+                  (string-append (BoxType-id box) " ")
+                  (BoxType-name box)))
+           (def (boxTypeDisplays (items : (List BoxType)))
+                :
+                (MutableList String)
+                (for/fold
+                  ((out (mutable-list-empty String)))
+                  ((i (in-range (int32 0) (list-size items))))
+                  (begin
+                    (mutable-list-add!
+                      out
+                      (box-type-display (list-ref items i)))
+                    out)))
+           (def (boxTypeById (items : (List BoxType)) (id : String))
+                :
+                (Nullable BoxType)
+                (for/fold
+                  ((found (nullable-none BoxType)))
+                  ((i (in-range (int32 0) (list-size items))))
+                  (if (nullable-null? found)
+                      (let ([box (list-ref items i)])
+                        (if (equal? (BoxType-id box) id)
+                            (nullable-some box)
+                            found))
+                      found)))
+           (def (boxTypeDisplayById
+                  (items : (List BoxType))
+                  (id : String))
+                :
+                String
+                (let ([box (boxTypeById items id)])
+                  (if (nullable-null? box)
+                      id
+                      (box-type-display (nullable-get box)))))
+           (def (addNullableBoxType
+                  (items : (MutableList BoxType))
+                  (box : (Nullable BoxType)))
+                :
+                Unit
+                (if (nullable-null? box)
+                    (begin)
+                    (mutable-list-add! items (nullable-get box))))))))
diff --git a/templates/ssd-review-parts/BoxTypeAndroid.ss b/templates/ssd-review-parts/BoxTypeAndroid.ss
new file mode 100644
index 0000000..d5852e7
--- /dev/null
+++ b/templates/ssd-review-parts/BoxTypeAndroid.ss
@@ -0,0 +1,80 @@
+(import (jerboa prelude))
+
+(def fragment
+     '((typed-kotlin-file
+         "com/sfb/ssdreview/BoxTypeAndroid.kt"
+         (kotlin-imports
+           (android content Context)
+           (android content res AssetManager)
+           (java io InputStream))
+         (typed-library (com sfb ssdreview)
+           (export boxTypeRawIsWeapon resolvedBoxTypeNameOr
+             resolvedBoxTypeIdOr boxTypesLoadAndroid
+             boxTypesResolveAndroid boxTypesDisplayAndroid)
+           (type Context) (type AssetManager) (type InputStream)
+           (extern
+             (contextAssets (context : Context))
+             :
+             AssetManager
+             (kotlin-member-get assets))
+           (extern
+             (assetManagerOpen (assets : AssetManager) (name : String))
+             :
+             InputStream
+             (kotlin-member-call open))
+           (extern
+             (boxTypeInputStreamClose (input : InputStream))
+             :
+             Unit
+             (kotlin-member-call close))
+           (def (boxTypesCsvText (context : Context))
+                :
+                String
+                (let ([input (assetManagerOpen
+                               (contextAssets context)
+                               "box_types.csv")])
+                  (let ([text (utf8->string
+                                (readBoundedBytes input (int 262144)))])
+                    (begin (boxTypeInputStreamClose input) text))))
+           (def (boxTypesLoadAndroid (context : Context))
+                :
+                (List BoxType)
+                (boxTypesFromCsvText (boxTypesCsvText context)))
+           (def (boxTypesResolveAndroid
+                  (context : Context)
+                  (raw : String))
+                :
+                (Nullable BoxType)
+                (resolveBoxType (boxTypesLoadAndroid context) raw))
+           (def (boxTypesDisplayAndroid
+                  (context : Context)
+                  (id : String))
+                :
+                String
+                (boxTypeDisplayById (boxTypesLoadAndroid context) id))
+           (def (resolvedBoxTypeNameOr
+                  (boxType : (Nullable BoxType))
+                  (fallback : String))
+                :
+                String
+                (if (nullable-null? boxType)
+                    fallback
+                    (BoxType-name (nullable-get boxType))))
+           (def (resolvedBoxTypeIdOr
+                  (boxType : (Nullable BoxType))
+                  (fallback : String))
+                :
+                String
+                (if (nullable-null? boxType)
+                    fallback
+                    (BoxType-id (nullable-get boxType))))
+           (def (boxTypeRawIsWeapon (context : Context) (raw : String))
+                :
+                Bool
+                (let ([text (string-trim raw)])
+                  (if (string-blank? text)
+                      #f
+                      (boxTypeIsWeapon
+                        (resolvedBoxTypeNameOr
+                          (boxTypesResolveAndroid context text)
+                          text)))))))))
diff --git a/templates/ssd-review-parts/BoxTypeCsv.ss b/templates/ssd-review-parts/BoxTypeCsv.ss
new file mode 100644
index 0000000..9331930
--- /dev/null
+++ b/templates/ssd-review-parts/BoxTypeCsv.ss
@@ -0,0 +1,39 @@
+(import (jerboa prelude))
+
+(def fragment
+     '((typed-kotlin-file
+         "com/sfb/ssdreview/BoxTypeCsv.kt"
+         (typed-library (com sfb ssdreview)
+           (export parseBoxTypeCsvLine boxTypesFromCsvText)
+           (extern
+             (boxTypeCsvStringLines (text : String))
+             :
+             (List String)
+             (kotlin-member-call lines))
+           (def (parseBoxTypeCsvLine (line : String))
+                :
+                (Nullable BoxType)
+                (if (not (string-matches-regex? line "^[^,]+,.+$"))
+                    (nullable-none BoxType)
+                    (let ([id (string-trim
+                                (string-substring-before line ","))]
+                          [name (string-trim
+                                  (string-replace-regex
+                                    line
+                                    "^[^,]*,"
+                                    ""))])
+                      (if (or (string-blank? id) (string-blank? name))
+                          (nullable-none BoxType)
+                          (nullable-some (make-BoxType id name))))))
+           (def (boxTypesFromCsvText (text : String))
+                :
+                (MutableList BoxType)
+                (let ([lines (boxTypeCsvStringLines text)])
+                  (for/fold
+                    ((items (mutable-list-empty BoxType)))
+                    ((i (in-range (int32 1) (list-size lines))))
+                    (begin
+                      (addNullableBoxType
+                        items
+                        (parseBoxTypeCsvLine (list-ref lines i)))
+                      items))))))))
diff --git a/templates/ssd-review-parts/BoxTypeLabelAndroid.ss b/templates/ssd-review-parts/BoxTypeLabelAndroid.ss
new file mode 100644
index 0000000..d92a9d6
--- /dev/null
+++ b/templates/ssd-review-parts/BoxTypeLabelAndroid.ss
@@ -0,0 +1,87 @@
+(import (jerboa prelude))
+
+(def fragment
+     '((typed-kotlin-file
+         "com/sfb/ssdreview/BoxTypeLabelAndroid.kt"
+         (kotlin-imports (android content Context))
+         (typed-library (com sfb ssdreview)
+           (export
+             defaultLabelForBoxTypeAndroid
+             resolvedLabelForBoxTypeAndroid
+             shouldReplaceLabelForBoxTypeAndroid
+             shouldKeepLabelForBoxTypeAndroid)
+           (type Context)
+           (extern
+             (boxTypesResolve (context : Context) (raw : String))
+             :
+             (Nullable BoxType)
+             (kotlin-call boxTypesResolveAndroid))
+           (def (nullableBoxTypeName (boxType : (Nullable BoxType)))
+                :
+                (Nullable String)
+                (if (nullable-null? boxType)
+                    (nullable-none String)
+                    (nullable-some (BoxType-name (nullable-get boxType)))))
+           (def (nullableBoxTypeDisplay (boxType : (Nullable BoxType)))
+                :
+                (Nullable String)
+                (if (nullable-null? boxType)
+                    (nullable-none String)
+                    (nullable-some
+                      (box-type-display (nullable-get boxType)))))
+           (def (boxTypeLabelMatchesAndroid
+                  (context : Context)
+                  (label : String)
+                  (boxType : String))
+                :
+                Bool
+                (let ([resolved (boxTypesResolve context boxType)])
+                  (boxTypeLabelMatches
+                    label
+                    boxType
+                    (nullableBoxTypeName resolved)
+                    (nullableBoxTypeDisplay resolved))))
+           (def (defaultLabelForBoxTypeAndroid
+                  (context : Context)
+                  (boxType : String))
+                :
+                String
+                (defaultBoxTypeLabel
+                  boxType
+                  (nullableBoxTypeName (boxTypesResolve context boxType))))
+           (def (resolvedLabelForBoxTypeAndroid
+                  (context : Context)
+                  (label : String)
+                  (boxType : String)
+                  (previousBoxType : String))
+                :
+                String
+                (resolvedBoxTypeLabel
+                  label
+                  (defaultLabelForBoxTypeAndroid context boxType)
+                  (boxTypeLabelMatchesAndroid
+                    context
+                    (string-trim label)
+                    previousBoxType)))
+           (def (shouldReplaceLabelForBoxTypeAndroid
+                  (context : Context)
+                  (label : String)
+                  (previousBoxType : String))
+                :
+                Bool
+                (shouldReplaceBoxTypeLabel
+                  label
+                  (boxTypeLabelMatchesAndroid
+                    context
+                    (string-trim label)
+                    previousBoxType)))
+           (def (shouldKeepLabelForBoxTypeAndroid
+                  (context : Context)
+                  (label : String)
+                  (previousBoxType : String))
+                :
+                Bool
+                (not (shouldReplaceLabelForBoxTypeAndroid
+                       context
+                       label
+                       previousBoxType)))))))
diff --git a/templates/ssd-review-parts/BoxTypeResolve.ss b/templates/ssd-review-parts/BoxTypeResolve.ss
new file mode 100644
index 0000000..9eda5c9
--- /dev/null
+++ b/templates/ssd-review-parts/BoxTypeResolve.ss
@@ -0,0 +1,125 @@
+(import (jerboa prelude))
+
+(def fragment
+     '((typed-kotlin-file
+         "com/sfb/ssdreview/BoxTypeResolve.kt"
+         (typed-library (com sfb ssdreview) (export resolveBoxType boxTypeIsWeapon)
+           (type Int32)
+           (record
+             BoxTypeMatch
+             ((count : Int32) (found : (Nullable BoxType))))
+           (def (boxTypeCompact (value : String))
+                :
+                String
+                (string-filter-letter-or-digit (string-lowercase value)))
+           (def (boxTypeNonWeaponTerm? (normalized : String))
+                :
+                Bool
+                (string-matches-regex?
+                  normalized
+                  ".*(charge|degradation|fighterbox|hangar|hit|internalweaponbay|link|mechlink|rail|round|stabilizer|targetaccentuator|targetacquisitiongear|targetacquisitionguide).*"))
+           (def (boxTypeWeaponTerm? (normalized : String))
+                :
+                Bool
+                (string-matches-regex?
+                  normalized
+                  ".*(add|antiproton|atomicmissile|axiontorpedo|bioelectricbolt|bombthrower|bosondrill|cannon|clusterbomb|deathboltrack|disruptor|drone|energyhowitzer|esg|fireball|fusion|gausscannon|hellbore|hellgun|hypercannon|hyperdrone|implosionbolt|implosiontorpedo|ioncannon|ionpulsecannon|kineticcannon|kineticwave|laser|massdriver|megaphaser|missile|mine|morter|mortar|neutronbeam|neutrongun|novacannon|optionmount|particlebeam|particlecannon|phaser|photon|plasma|positronlancet|ppd|prospectingcannon|proton|pulsecannon|pulseemitter|quantumcannon|railgun|rocket|sfg|shortrangecannon|sonicpulser|spaceauger|stingtorpedo|subspacerocket|tachyonbeam|tachyongun|tachyonmissile|torp|torpedo|trh|trl|webbreaker|webcaster|websnare).*"))
+           (def (boxTypeIsWeapon (raw : String))
+                :
+                Bool
+                (let ([normalized (boxTypeCompact raw)])
+                  (if (string-blank? normalized)
+                      #f
+                      (if (boxTypeNonWeaponTerm? normalized)
+                          #f
+                          (boxTypeWeaponTerm? normalized)))))
+           (def (boxTypeExactMatch
+                  (box : BoxType)
+                  (text : String)
+                  (lowered : String)
+                  (normalized : String))
+                :
+                Bool
+                (or (equal? (BoxType-id box) text)
+                    (or (equal?
+                          (string-lowercase (box-type-display box))
+                          lowered)
+                        (or (equal?
+                              (string-lowercase (BoxType-name box))
+                              lowered)
+                            (equal?
+                              (boxTypeCompact (BoxType-name box))
+                              normalized)))))
+           (def (boxTypePartialMatch
+                  (box : BoxType)
+                  (lowered : String)
+                  (normalized : String))
+                :
+                Bool
+                (or (string-contains?
+                      (string-lowercase (box-type-display box))
+                      lowered)
+                    (string-contains?
+                      (boxTypeCompact (BoxType-name box))
+                      normalized)))
+           (def (findExactBoxType
+                  (items : (List BoxType))
+                  (text : String)
+                  (lowered : String)
+                  (normalized : String))
+                :
+                (Nullable BoxType)
+                (for/fold
+                  ((found (nullable-none BoxType)))
+                  ((i (in-range (int32 0) (list-size items))))
+                  (if (nullable-null? found)
+                      (let ([box (list-ref items i)])
+                        (if (boxTypeExactMatch box text lowered normalized)
+                            (nullable-some box)
+                            found))
+                      found)))
+           (def (findPartialBoxTypes
+                  (items : (List BoxType))
+                  (lowered : String)
+                  (normalized : String))
+                :
+                BoxTypeMatch
+                (for/fold
+                  ((match (make-BoxTypeMatch
+                            (int32 0)
+                            (nullable-none BoxType))))
+                  ((i (in-range (int32 0) (list-size items))))
+                  (let ([box (list-ref items i)])
+                    (if (boxTypePartialMatch box lowered normalized)
+                        (make-BoxTypeMatch
+                          (+ (BoxTypeMatch-count match) (int32 1))
+                          (nullable-some box))
+                        match))))
+           (def (singleBoxTypeMatch (match : BoxTypeMatch))
+                :
+                (Nullable BoxType)
+                (if (= (BoxTypeMatch-count match) (int32 1))
+                    (BoxTypeMatch-found match)
+                    (nullable-none BoxType)))
+           (def (resolveBoxType
+                  (items : (List BoxType))
+                  (raw : String))
+                :
+                (Nullable BoxType)
+                (let ([text (string-trim raw)])
+                  (if (string-blank? text)
+                      (nullable-none BoxType)
+                      (let ([normalized (boxTypeCompact text)]
+                            [lowered (string-lowercase text)])
+                        (let ([exact (findExactBoxType
+                                       items
+                                       text
+                                       lowered
+                                       normalized)])
+                          (if (nullable-null? exact)
+                              (singleBoxTypeMatch
+                                (findPartialBoxTypes
+                                  items
+                                  lowered
+                                  normalized))
+                              exact))))))))))
diff --git a/templates/ssd-review-parts/ButtonKind.ss b/templates/ssd-review-parts/ButtonKind.ss
new file mode 100644
index 0000000..101b19d
--- /dev/null
+++ b/templates/ssd-review-parts/ButtonKind.ss
@@ -0,0 +1,41 @@
+(import (jerboa prelude))
+
+(def fragment
+     '((typed-kotlin-file
+         "com/sfb/ssdreview/ButtonKind.kt"
+         (typed-library (com sfb ssdreview) (export ButtonKind) (type Int32)
+           (type Float32)
+           (enum ButtonKind
+             ((backgroundColor : Int32)
+               (foregroundColor : Int32)
+               (textSp : Float32)
+               (heightDp : Int32))
+             (PRIMARY
+               (reviewPrimaryColor)
+               (reviewWhiteColor)
+               (float32 18.0)
+               (int32 56))
+             (ACCENT
+               (reviewAccentColor)
+               (reviewWhiteColor)
+               (float32 18.0)
+               (int32 56))
+             (MODE
+               (reviewModeColor)
+               (reviewDarkTextColor)
+               (float32 17.0)
+               (int32 54))
+             (NAV (reviewNavColor)
+                  (reviewWhiteColor)
+                  (float32 17.0)
+                  (int32 52))
+             (NEUTRAL
+               (reviewNeutralColor)
+               (reviewDarkTextColor)
+               (float32 15.0)
+               (int32 48))
+             (DANGER
+               (reviewDangerColor)
+               (reviewWhiteColor)
+               (float32 16.0)
+               (int32 50)))))))
diff --git a/templates/ssd-review-parts/BytesHex.ss b/templates/ssd-review-parts/BytesHex.ss
new file mode 100644
index 0000000..548b27a
--- /dev/null
+++ b/templates/ssd-review-parts/BytesHex.ss
@@ -0,0 +1,12 @@
+(import (jerboa prelude))
+
+(def fragment
+     '((typed-kotlin-file
+         "com/sfb/ssdreview/BytesHex.kt"
+         (typed-library
+           (com sfb ssdreview)
+           (export bytesToLowerHex)
+           (def (bytesToLowerHex (bytes : Bytes))
+                :
+                String
+                (bytes->lower-hex bytes))))))
diff --git a/templates/ssd-review-parts/ClientCrash.ss b/templates/ssd-review-parts/ClientCrash.ss
new file mode 100644
index 0000000..00edcc0
--- /dev/null
+++ b/templates/ssd-review-parts/ClientCrash.ss
@@ -0,0 +1,37 @@
+(import (jerboa prelude))
+
+(def fragment
+     '((typed-kotlin-file
+         "com/sfb/ssdreview/ClientCrash.kt"
+         (kotlin-imports
+           (java lang Thread)
+           (java lang Thread UncaughtExceptionHandler))
+         (typed-library (com sfb ssdreview) (export finishUncaughtException)
+           (type Int32) (type Thread) (type Throwable)
+           (type UncaughtExceptionHandler)
+           (extern
+             (systemExit (code : Int32))
+             :
+             Unit
+             (kotlin-call java lang System exit))
+           (extern
+             (handlerUncaughtException
+               (handler : UncaughtExceptionHandler)
+               (crashingThread : Thread)
+               (error : Throwable))
+             :
+             Unit
+             (kotlin-member-call uncaughtException))
+           (def (finishUncaughtException
+                  (handler : (Nullable UncaughtExceptionHandler))
+                  (crashingThread : Thread)
+                  (error : Throwable)
+                  (exitCode : Int32))
+                :
+                Unit
+                (if (nullable-null? handler)
+                    (systemExit exitCode)
+                    (handlerUncaughtException
+                      (nullable-get handler)
+                      crashingThread
+                      error)))))))
diff --git a/templates/ssd-review-parts/ClientCrashInstall.ss b/templates/ssd-review-parts/ClientCrashInstall.ss
new file mode 100644
index 0000000..f0de179
--- /dev/null
+++ b/templates/ssd-review-parts/ClientCrashInstall.ss
@@ -0,0 +1,155 @@
+(import (jerboa prelude))
+
+(def fragment
+     '((typed-kotlin-file
+         "com/sfb/ssdreview/ClientCrashInstall.kt"
+         (kotlin-imports
+           (java lang Runnable)
+           (java lang Thread)
+           (java lang Thread UncaughtExceptionHandler)
+           (org json JSONObject))
+         (typed-library (com sfb ssdreview)
+          (export mainActivityInstallClientCrashLoggerHandler)
+          (type Int) (type Int32) (type Exception) (type Runnable)
+          (type Thread) (type Throwable)
+          (type UncaughtExceptionHandler) (type JSONObject)
+          (type MainActivity)
+          (extern
+            (threadDefaultUncaughtExceptionHandlerRaw)
+            :
+            (Nullable UncaughtExceptionHandler)
+            (kotlin-call Thread getDefaultUncaughtExceptionHandler))
+          (extern
+            (threadSetDefaultUncaughtExceptionHandlerRaw
+              (handler : UncaughtExceptionHandler))
+            :
+            Unit
+            (kotlin-call Thread setDefaultUncaughtExceptionHandler))
+          (extern
+            (makeCrashThread (runnable : Runnable))
+            :
+            Thread
+            (kotlin-call Thread))
+          (extern
+            (threadStartCrashRaw (thread : Thread))
+            :
+            Unit
+            (kotlin-member-call start))
+          (extern
+            (threadJoinCrashRaw (thread : Thread) (timeoutMillis : Int))
+            :
+            Unit
+            (kotlin-member-call join))
+          (extern
+            (threadNameRaw (thread : Thread))
+            :
+            String
+            (kotlin-member-get name))
+          (extern
+            (mainActivityCrashLogPayloadRaw
+              (activity : MainActivity)
+              (event : String)
+              (fields : JSONObject))
+            :
+            JSONObject
+            (kotlin-call mainActivityClientLogPayloadRaw))
+          (extern
+            (sendClientLogBlockingRawForCrash
+              (payload : JSONObject)
+              (readTimeoutMillis : Int32))
+            :
+            Unit
+            (kotlin-call sendClientLogBlocking))
+          (extern
+            (mainActivityThrowableLogRaw (error : Throwable))
+            :
+            JSONObject
+            (kotlin-call throwableLogJsonForThrowable))
+          (def (startCrashSenderThread
+                 (activity : MainActivity)
+                 (payload : JSONObject)
+                 (readTimeoutMillis : Int32))
+               :
+               Thread
+               (let ([sender (makeCrashThread
+                               (object
+                                 Runnable
+                                 (override
+                                   (run)
+                                   :
+                                   Unit
+                                   (mainActivityCrashSendClientLogRaw
+                                     activity
+                                     payload
+                                     readTimeoutMillis))))])
+                 (begin (threadStartCrashRaw sender) sender)))
+          (def (mainActivityCrashSendClientLogRaw
+                 (activity : MainActivity)
+                 (payload : JSONObject)
+                 (readTimeoutMillis : Int32))
+               :
+               Unit
+               (sendClientLogBlockingRawForCrash
+                 payload
+                 readTimeoutMillis))
+          (def (crashPayloadFor
+                 (activity : MainActivity)
+                 (crashingThread : Thread)
+                 (throwable : Throwable))
+               :
+               JSONObject
+               (try (mainActivityCrashLogPayloadRaw
+                      activity
+                      "ssd-uncaught-exception"
+                      (clientLogCrashFields
+                        (nullableTextOrEmpty
+                          (nullable-some (threadNameRaw crashingThread)))
+                        (mainActivityThrowableLogRaw throwable)))
+                    (catch
+                      (error : Exception)
+                      (clientLogFallbackPayload
+                        "ssd-uncaught-exception"))))
+          (def (sendCrashPayloadBlocking
+                 (activity : MainActivity)
+                 (payload : JSONObject)
+                 (readTimeoutMillis : Int32)
+                 (joinTimeoutMillis : Int))
+               :
+               Unit
+               (try (let ([sender (startCrashSenderThread
+                                    activity
+                                    payload
+                                    readTimeoutMillis)])
+                      (threadJoinCrashRaw sender joinTimeoutMillis))
+                    (catch (error : Exception) (begin))))
+          (def (mainActivityInstallClientCrashLoggerHandler
+                 (activity : MainActivity)
+                 (readTimeoutMillis : Int32)
+                 (joinTimeoutMillis : Int))
+               :
+               Unit
+               (let ([previous (threadDefaultUncaughtExceptionHandlerRaw)])
+                 (threadSetDefaultUncaughtExceptionHandlerRaw
+                   (object
+                     UncaughtExceptionHandler
+                     (override
+                       (uncaughtException
+                         (crashingThread : Thread)
+                         (throwable : Throwable))
+                       :
+                       Unit
+                       (let ([payload (crashPayloadFor
+                                        activity
+                                        crashingThread
+                                        throwable)])
+                         (begin
+                           (sendCrashPayloadBlocking
+                             activity
+                             payload
+                             readTimeoutMillis
+                             joinTimeoutMillis)
+                           (finishUncaughtException
+                             previous
+                             crashingThread
+                             throwable
+                             (int32 2)))))))))))))
diff --git a/templates/ssd-review-parts/ClientLogJson.ss b/templates/ssd-review-parts/ClientLogJson.ss
new file mode 100644
index 0000000..bd070bd
--- /dev/null
+++ b/templates/ssd-review-parts/ClientLogJson.ss
@@ -0,0 +1,143 @@
+(import (jerboa prelude))
+
+(def fragment
+     '((typed-kotlin-file
+         "com/sfb/ssdreview/ClientLogJson.kt"
+         (kotlin-imports (org json JSONObject))
+         (typed-library (com sfb ssdreview)
+           (export clientLogPayloadJson clientLogCrashFields
+             clientLogFallbackPayload appLogPackageInfo appLogVersionInfo
+             androidPackageVersionCode deviceLogInfoJson throwableLogJson
+             statusLogFields truncateClientLogStack)
+           (type JSONObject) (type Int) (type Int32) (type String)
+           (def (clientLogPdfJson
+                  (pdfName : String)
+                  (page : Int32)
+                  (pageCount : Int32))
+                :
+                JSONObject
+                (let ([out (json-object-empty)])
+                  (begin
+                    (json-object-put-string! out "name" pdfName)
+                    (json-object-put-int32! out "page" page)
+                    (json-object-put-int32! out "pages" pageCount)
+                    out)))
+           (def (clientLogPayloadJson (event : String) (uptimeMs : Int) (threadName : String)
+                  (app : JSONObject) (device : JSONObject)
+                  (pdfName : String) (page : Int32) (pageCount : Int32)
+                  (selectedGroupId : String) (fields : JSONObject))
+                :
+                JSONObject
+                (let ([out (json-object-empty)])
+                  (begin
+                    (json-object-put-int32! out "schema" (int32 1))
+                    (json-object-put-string! out "event" event)
+                    (json-object-put-int!
+                      out
+                      "client-time-ms"
+                      (system-current-time-millis))
+                    (json-object-put-int! out "uptime-ms" uptimeMs)
+                    (json-object-put-string! out "thread" threadName)
+                    (json-object-put-json-object! out "app" app)
+                    (json-object-put-json-object! out "device" device)
+                    (json-object-put-json-object!
+                      out
+                      "pdf"
+                      (clientLogPdfJson pdfName page pageCount))
+                    (json-object-put-string!
+                      out
+                      "selected-group"
+                      selectedGroupId)
+                    (json-object-put-json-object! out "fields" fields)
+                    out)))
+           (def (clientLogCrashFields
+                  (threadName : String)
+                  (error : JSONObject))
+                :
+                JSONObject
+                (let ([out (json-object-empty)])
+                  (begin
+                    (json-object-put-string!
+                      out
+                      "crashing-thread"
+                      threadName)
+                    (json-object-put-json-object! out "error" error)
+                    out)))
+           (def (clientLogFallbackPayload (event : String))
+                :
+                JSONObject
+                (let ([out (json-object-empty)])
+                  (begin (json-object-put-string! out "event" event) out)))
+           (def (appLogPackageInfo (packageName : String))
+                :
+                JSONObject
+                (let ([out (json-object-empty)])
+                  (begin
+                    (json-object-put-string! out "package" packageName)
+                    out)))
+           (def (appLogVersionInfo
+                  (packageName : String)
+                  (versionName : String)
+                  (versionCode : Int))
+                :
+                JSONObject
+                (let ([out (appLogPackageInfo packageName)])
+                  (begin
+                    (json-object-put-string!
+                      out
+                      "version-name"
+                      versionName)
+                    (json-object-put-int! out "version-code" versionCode)
+                    out)))
+           (def (androidPackageVersionCode
+                  (sdk : Int32)
+                  (apiP : Int32)
+                  (longVersionCode : Int)
+                  (legacyVersionCode : Int))
+                :
+                Int
+                (if (>= sdk apiP) longVersionCode legacyVersionCode))
+           (def (deviceLogInfoJson
+                  (sdk : Int32)
+                  (release : String)
+                  (manufacturer : String)
+                  (model : String))
+                :
+                JSONObject
+                (let ([out (json-object-empty)])
+                  (begin
+                    (json-object-put-int32! out "sdk" sdk)
+                    (json-object-put-string! out "release" release)
+                    (json-object-put-string!
+                      out
+                      "manufacturer"
+                      manufacturer)
+                    (json-object-put-string! out "model" model)
+                    out)))
+           (def (throwableLogJson
+                  (className : String)
+                  (message : String)
+                  (stack : String))
+                :
+                JSONObject
+                (let ([out (json-object-empty)])
+                  (begin
+                    (json-object-put-string! out "class" className)
+                    (json-object-put-string! out "message" message)
+                    (json-object-put-string! out "stack" stack)
+                    out)))
+           (def (truncateClientLogStack (stack : String))
+                :
+                String
+                (if (<= (string-length stack) 12000)
+                    stack
+                    (string-append
+                      (string-take stack (int32 12000))
+                      "...[truncated]")))
+           (def (statusLogFields (text : String))
+                :
+                JSONObject
+                (let ([out (json-object-empty)])
+                  (begin
+                    (json-object-put-string! out "status" text)
+                    out)))))))
diff --git a/templates/ssd-review-parts/ClientLogTransport.ss b/templates/ssd-review-parts/ClientLogTransport.ss
new file mode 100644
index 0000000..0d3b637
--- /dev/null
+++ b/templates/ssd-review-parts/ClientLogTransport.ss
@@ -0,0 +1,201 @@
+(import (jerboa prelude))
+
+(def fragment
+     '((typed-kotlin-file
+         "com/sfb/ssdreview/ClientLogTransport.kt"
+         (kotlin-imports (java io InputStream) (java io OutputStream) (java net URL)
+           (java net URLConnection) (javax net ssl HttpsURLConnection)
+           (org json JSONObject))
+         (typed-library (com sfb ssdreview)
+          (export sendClientLogBlocking) (type Exception)
+          (type HttpsURLConnection) (type InputStream) (type Int)
+          (type Int32) (type JSONObject) (type OutputStream)
+          (type URL) (type URLConnection)
+          (extern
+            (clientLogEndpointValue)
+            :
+            String
+            (kotlin-value CLIENT_LOG_ENDPOINT))
+          (extern
+            (clientLogConnectTimeoutMillisValue)
+            :
+            Int32
+            (kotlin-value CLIENT_LOG_CONNECT_TIMEOUT_MS))
+          (extern
+            (clientLogUrl (value : String))
+            :
+            URL
+            (kotlin-call URL))
+          (extern
+            (clientLogOpenConnection (url : URL))
+            :
+            URLConnection
+            (kotlin-member-call openConnection))
+          (extern
+            (clientLogHttpsConnection (connection : URLConnection))
+            :
+            HttpsURLConnection
+            (kotlin-cast HttpsURLConnection))
+          (extern
+            (clientLogJsonToString (payload : JSONObject))
+            :
+            String
+            (kotlin-member-call toString))
+          (extern
+            (clientLogConnectionRequestMethodSet
+              (connection : HttpsURLConnection)
+              (method : String))
+            :
+            Unit
+            (kotlin-member-set requestMethod))
+          (extern
+            (clientLogConnectionConnectTimeoutSet
+              (connection : HttpsURLConnection)
+              (timeoutMillis : Int32))
+            :
+            Unit
+            (kotlin-member-set connectTimeout))
+          (extern
+            (clientLogConnectionReadTimeoutSet
+              (connection : HttpsURLConnection)
+              (timeoutMillis : Int32))
+            :
+            Unit
+            (kotlin-member-set readTimeout))
+          (extern
+            (clientLogConnectionDoOutputSet
+              (connection : HttpsURLConnection)
+              (enabled : Bool))
+            :
+            Unit
+            (kotlin-member-set doOutput))
+          (extern
+            (clientLogConnectionSetRequestProperty
+              (connection : HttpsURLConnection)
+              (name : String)
+              (value : String))
+            :
+            Unit
+            (kotlin-member-call setRequestProperty))
+          (extern
+            (clientLogConnectionSetFixedLengthStreamingMode
+              (connection : HttpsURLConnection)
+              (length : Int))
+            :
+            Unit
+            (kotlin-member-call setFixedLengthStreamingMode))
+          (extern
+            (clientLogConnectionOutputStream
+              (connection : HttpsURLConnection))
+            :