Package embedded Jerboa programs from typed Android specs

ober

4789f30248a740bf48321e8287322fd015bdca58

diff --git a/Makefile b/Makefile
index e6a28c1..c16ed24 100644
--- a/Makefile
+++ b/Makefile
@@ -60,9 +60,16 @@ test: check-no-raw-kotlin clean generate
 	$(JERBOA) jandroid.ss generate tests/fixtures/fragment-app.ss build/fragment-fixture
 	test -f build/fragment-fixture/app/src/main/java/org/jerboa/fragmentfixture/FragmentValue.kt # gitsafe:ignore
 	grep -q 'fun fragmentValue(): String' build/fragment-fixture/app/src/main/java/org/jerboa/fragmentfixture/FragmentValue.kt # gitsafe:ignore
+	$(JERBOA) jandroid.ss generate tests/fixtures/native-packaging-app.ss build/native-packaging
+	grep -q 'android:extractNativeLibs="true"' build/native-packaging/app/src/main/AndroidManifest.xml
+	grep -q 'useLegacyPackaging = true' build/native-packaging/app/build.gradle.kts
+	grep -q 'keepDebugSymbols += "\*\*/libembedded_program.so"' build/native-packaging/app/build.gradle.kts
 	$(JERBOA) jandroid.ss generate $(ORIGINAL_TACTICS_EXAMPLE) $(ORIGINAL_TACTICS_BUILD_DIR)
 	test -f $(ORIGINAL_TACTICS_BUILD_DIR)/app/src/main/java/com/jerboa/originaltactics/ShipUiNames.kt
 	grep -q 'label.length <= maxChars' $(ORIGINAL_TACTICS_BUILD_DIR)/app/src/main/java/com/jerboa/originaltactics/ShipUiNames.kt
+	grep -q 'android:extractNativeLibs="true"' $(ORIGINAL_TACTICS_BUILD_DIR)/app/src/main/AndroidManifest.xml
+	grep -q 'useLegacyPackaging = true' $(ORIGINAL_TACTICS_BUILD_DIR)/app/build.gradle.kts
+	grep -q 'keepDebugSymbols += "\*\*/liboriginaltactics_program.so"' $(ORIGINAL_TACTICS_BUILD_DIR)/app/build.gradle.kts
 	$(JERBOA) jandroid.ss generate $(SSD_EXAMPLE) $(SSD_CONTRACT_BUILD_DIR)
 	$(MAKE) check-generated-app-contracts
 	$(MAKE) security
diff --git a/jandroid.ss b/jandroid.ss
index 37a1a36..86922c1 100644
--- a/jandroid.ss
+++ b/jandroid.ss
@@ -230,6 +230,7 @@
   (let ([version (entry-value (entries spec) 'ndk-version #f)])
     (if version (immutable-version version 'ndk-version) #f)))
 (def (allow-backup spec) (entry-value (entries spec) 'allow-backup #f))
+(def (extract-native-libs spec) (entry-value (entries spec) 'extract-native-libs #f))
 (def (uses-cleartext-traffic spec) (entry-value (entries spec) 'uses-cleartext-traffic #f))
 (def (theme spec) (entry-value (entries spec) 'theme "@style/AppTheme"))
 (def (activity-screen-orientation spec) (entry-value (entries spec) 'activity-screen-orientation #f))
@@ -241,6 +242,8 @@
 (def (abi-filters spec) (entry-values (entries spec) 'abi-filter))
 (def (asset-dirs spec) (entry-values (entries spec) 'asset-dir))
 (def (jni-lib-dirs spec) (entry-values (entries spec) 'jni-lib-dir))
+(def (keep-jni-debug-symbols spec)
+  (entry-values (entries spec) 'keep-jni-debug-symbol))
 (def (kotlin-source-dirs spec) (entry-values (entries spec) 'kotlin-source-dir))
 (def (raw-files spec) (entry-forms (entries spec) 'file))
 (def (gradle-property-forms spec) (entry-forms (entries spec) 'gradle-property))
@@ -569,7 +572,8 @@
 
 (def (app-build-gradle spec)
   (let ((ndk (ndk-version spec))
-        (abis (abi-filters spec)))
+        (abis (abi-filters spec))
+        (keep-jni (keep-jni-debug-symbols spec)))
     (string-append
       "plugins {\n"
       "    id(\"com.android.application\")\n"
@@ -594,6 +598,27 @@
       "        sourceCompatibility = JavaVersion.VERSION_" (format "~a" (jvm-toolchain spec)) "\n"
       "        targetCompatibility = JavaVersion.VERSION_" (format "~a" (jvm-toolchain spec)) "\n"
       "    }\n"
+      (if (or (extract-native-libs spec) (not (null? keep-jni)))
+          (string-append
+            "\n"
+            "    packaging {\n"
+            "        jniLibs {\n"
+            (if (extract-native-libs spec)
+                "            useLegacyPackaging = true\n"
+                "")
+            (apply string-append
+                   (map (lambda (pattern)
+                          (unless (string? pattern)
+                            (error 'keep-jni-debug-symbol
+                                   "expected a string pattern" pattern))
+                          (string-append
+                            "            keepDebugSymbols += "
+                            (kotlin-string pattern)
+                            "\n"))
+                        keep-jni))
+            "        }\n"
+            "    }\n")
+          "")
       "}\n"
       (if (kotlin? spec)
           (string-append "\n"
@@ -643,6 +668,9 @@
     "\n"
     "    <application\n"
     "        android:allowBackup=\"" (bool-string (allow-backup spec)) "\"\n"
+    (if (extract-native-libs spec)
+        "        android:extractNativeLibs=\"true\"\n"
+        "")
     "        android:label=\"" (xml-escape (app-name spec)) "\"\n"
     (if (uses-cleartext-traffic spec)
         "        android:usesCleartextTraffic=\"true\"\n"
diff --git a/templates/original-tactics-parts/RulesClient.ss b/templates/original-tactics-parts/RulesClient.ss
index e8551fe..dbe5c0d 100644
--- a/templates/original-tactics-parts/RulesClient.ss
+++ b/templates/original-tactics-parts/RulesClient.ss
@@ -10,6 +10,7 @@
                       (java net URL)
                       (java net URLConnection)
                       (java nio charset Charset)
+                      (java util concurrent locks ReentrantLock)
                       (kotlin text Charsets)
                       (org json JSONObject))
       (typed-library (com jerboa originaltactics)
@@ -22,11 +23,14 @@
         (type Int32)
         (type JSONObject)
         (type OutputStream)
+        (type ReentrantLock)
         (type Throwable)
         (type URL)
         (type URLConnection)
         (extern (contextCacheDir (context : Context)) : File
           (kotlin-member-get cacheDir))
+        (extern (contextCodeCacheDir (context : Context)) : File
+          (kotlin-member-get codeCacheDir))
         (extern (contextFilesDir (context : Context)) : File
           (kotlin-member-get filesDir))
         (extern (fileAbsolutePath (file : File)) : String
@@ -35,6 +39,12 @@
           (kotlin-member-call resolve))
         (extern (fileMkdirs (file : File)) : Bool
           (kotlin-member-call mkdirs))
+        (extern (fileExists (file : File)) : Bool
+          (kotlin-member-call exists))
+        (extern (fileReadText
+                  (file : File)
+                  (charset : Charset)) : String
+          (kotlin-member-call readText))
         (extern (fileAppendText
                   (file : File)
                   (text : String)
@@ -54,6 +64,17 @@
                   (key : String)
                   (value : String)) : JSONObject
           (kotlin-member-call put))
+        (extern (jsonPutAny
+                  (json : JSONObject)
+                  (key : String)
+                  (value : Any)) : JSONObject
+          (kotlin-member-call put))
+        (extern (jsonNull) : Any
+          (kotlin-value JSONObject NULL))
+        (extern (boolAsAny (value : Bool)) : Any
+          (kotlin-cast Any))
+        (extern (stringAsAny (value : String)) : Any
+          (kotlin-cast Any))
         (extern (jsonText (json : JSONObject)) : String
           (kotlin-member-call toString))
         (extern (urlOpenConnection (url : URL)) : URLConnection
@@ -117,15 +138,25 @@
           (kotlin-member-call close))
         (extern (illegalStateException (message : String)) : Throwable
           (kotlin-call IllegalStateException))
+        (extern (throwableMessage (error : Throwable)) : (Nullable String)
+          (kotlin-member-get message))
+        (extern (lockAcquire (lock : ReentrantLock)) : Unit
+          (kotlin-member-call lock))
+        (extern (lockRelease (lock : ReentrantLock)) : Unit
+          (kotlin-member-call unlock))
         (extern (removeSuffix
                   (text : String)
                   (suffix : String)) : String
           (kotlin-member-call removeSuffix))
+        (extern (stringTakeLast (text : String) (count : Int32)) : String
+          (kotlin-member-call takeLast))
         (extern (embeddedRulesAvailable
                   (cacheDir : String)) : Bool
           (kotlin-call EmbeddedRulesBridge isAvailable))
         (extern (embeddedRulesHandle (body : String)) : String
           (kotlin-call EmbeddedRulesBridge handleServiceJson))
+        (extern (embeddedRulesLastError) : String
+          (kotlin-call EmbeddedRulesBridge lastError))
         (val EMBEDDED_SERVICE_ENDPOINT : String
           "embedded://sfb-rules/api/service"
           (modifiers private const))
@@ -135,9 +166,6 @@
         (val EMBEDDED_CLIENT_LOG_ENDPOINT : String
           "embedded://sfb-rules/api/client-log"
           (modifiers private const))
-        (val EXTERNAL_CLIENT_LOG_ENDPOINT : String
-          "http://10.66.60.2:8799/api/client-log"
-          (modifiers private const))
         (def (httpSuccessCode? (code : Int32)) : Bool
           (and (>= code (int32 200)) (<= code (int32 299))))
         (def (newHttpConnection (text : String)) : HttpURLConnection
@@ -176,6 +204,8 @@
             (kotlin-call System loadLibrary))
           (extern (nativeReady (cacheDir : String)) : Bool
             (kotlin-external))
+          (extern (nativeLastError) : String
+            (kotlin-external))
           (extern (nativeHandleServiceJson (body : String)) : String
             (kotlin-external))
           (var loadError : (Nullable Throwable)
@@ -199,12 +229,29 @@
                      (begin
                        (set! loadError (nullable-some error))
                        #f)))))
+          (def (lastError) : String
+            (if (nullable-null? loadError)
+              (if loaded
+                (try
+                  (nativeLastError)
+                  (catch (error : Throwable)
+                    (let ((message (throwableMessage error)))
+                      (if (nullable-null? message)
+                        "native bridge reported unavailable"
+                        (nullable-get message)))))
+                "native library not loaded")
+              (let ((message (throwableMessage (nullable-get loadError))))
+                (if (nullable-null? message)
+                  "native bridge initialization failed"
+                  (nullable-get message)))))
           (def (handleServiceJson (body : String)) : String
             (begin
               (if (not loaded)
                 (throw
                   (illegalStateException
-                    "Embedded rules bridge is not available: native library not loaded")
+                    (string-append
+                      "Embedded rules bridge is not available: "
+                      (lastError)))
                   Unit)
                 (begin))
               (nativeHandleServiceJson body))))
@@ -214,26 +261,65 @@
             (modifiers private))
           (val fallbackEndpointValue : String fallbackEndpoint
             (modifiers private))
-          (val embeddedAvailable : Bool
-            (embeddedRulesAvailable
-              (fileAbsolutePath (contextCacheDir context)))
+          (var embeddedChecked : Bool #f
+            (modifiers @Volatile private))
+          (var embeddedAvailable : Bool #f
+            (modifiers @Volatile private))
+          (var embeddedError : (Nullable String)
+            (nullable-none String)
+            (modifiers @Volatile private))
+          (val serviceLock : ReentrantLock (new ReentrantLock)
+            (modifiers private))
+          (val initializationLock : ReentrantLock (new ReentrantLock)
             (modifiers private))
           (def (serviceEndpoint) : String
-            (if embeddedAvailable
-              EMBEDDED_SERVICE_ENDPOINT
-              fallbackEndpointValue))
+            EMBEDDED_SERVICE_ENDPOINT)
           (def (healthEndpoint) : String
-            (if embeddedAvailable
-              EMBEDDED_HEALTH_ENDPOINT
-              (string-append
-                (removeSuffix
-                  fallbackEndpointValue
-                  "/api/service")
-                "/health")))
+            EMBEDDED_HEALTH_ENDPOINT)
           (def (clientLogEndpoint) : String
-            EXTERNAL_CLIENT_LOG_ENDPOINT)
+            EMBEDDED_CLIENT_LOG_ENDPOINT)
           (def (modeLabel) : String
-            (if embeddedAvailable "embedded" "http-fallback"))
+            (if (not embeddedChecked)
+              "embedded-pending"
+              (if embeddedAvailable "embedded" "embedded-error")))
+          (def (startupTrace) : JSONObject
+            (jsonPutString
+              (jsonPutAny
+                (jsonPutAny
+                  (jsonPutString
+                    (jsonPutBool
+                      (jsonObject)
+                      "checked?"
+                      embeddedChecked)
+                    "transport"
+                    (modeLabel))
+                  "available?"
+                  (boolAsAny embeddedAvailable))
+                "error"
+                (if (nullable-null? embeddedError)
+                  (jsonNull)
+                  (stringAsAny (nullable-get embeddedError))))
+              "startup-trace"
+              (nativeStartupTraceText)))
+          (def (nativeStartupTraceText) : String
+            (modifiers private)
+            (try
+              (let ((trace
+                      (fileResolve
+                        (contextCodeCacheDir contextValue)
+                        "originaltactics-rules-startup.trace")))
+                (if (fileExists trace)
+                  (stringTakeLast
+                    (fileReadText trace (utf8Charset))
+                    (int32 4000))
+                  ""))
+              (catch (error : Throwable)
+                (let ((message (throwableMessage error)))
+                  (string-append
+                    "failed to read native startup trace: "
+                    (if (nullable-null? message)
+                      "unknown error"
+                      (nullable-get message)))))))
           (def (getHealth (url : String)) : String
             (if (equal? url EMBEDDED_HEALTH_ENDPOINT)
               (jsonText
@@ -242,7 +328,10 @@
                     (jsonPutString
                       (jsonPutString
                         (jsonPutString
-                          (jsonPutBool (jsonObject) "ok?" #t)
+                          (jsonPutBool
+                            (jsonObject)
+                            "ok?"
+                            (ensureEmbeddedReady))
                           "service"
                           "sfb-rules")
                         "engine"
@@ -252,17 +341,67 @@
                     "endpoint"
                     EMBEDDED_SERVICE_ENDPOINT)
                   "client-log-endpoint"
-                  EXTERNAL_CLIENT_LOG_ENDPOINT))
-              (httpGet url)))
+                  EMBEDDED_CLIENT_LOG_ENDPOINT))
+              (throw
+                (illegalStateException
+                  (string-append
+                    "External rules server fallback is disabled; requested "
+                    url))
+                String)))
           (def (post
                  (url : String)
                  (body : String)
                  (readTimeoutMillis : Int32)) : String
             (if (equal? url EMBEDDED_SERVICE_ENDPOINT)
-              (embeddedRulesHandle body)
+              (if (not (ensureEmbeddedReady))
+                (throw
+                  (illegalStateException
+                    (string-append
+                      "Embedded rules bridge is not ready: "
+                      (if (nullable-null? embeddedError)
+                        "native bridge unavailable"
+                        (nullable-get embeddedError))))
+                  String)
+                (runEmbeddedRequest body readTimeoutMillis))
               (if (equal? url EMBEDDED_CLIENT_LOG_ENDPOINT)
                 (writeEmbeddedClientLog body)
-                (httpPost url body readTimeoutMillis))))
+                (throw
+                  (illegalStateException
+                    (string-append
+                      "External rules server fallback is disabled; requested "
+                      url))
+                  String))))
+          (def (runEmbeddedRequest
+                 (body : String)
+                 (readTimeoutMillis : Int32)) : String
+            (modifiers private)
+            (begin
+              (lockAcquire serviceLock)
+              (try-finally
+                (embeddedRulesHandle body)
+                (lockRelease serviceLock))))
+          (def (ensureEmbeddedReady) : Bool
+            (modifiers private)
+            (if embeddedChecked
+              embeddedAvailable
+              (begin
+                (lockAcquire initializationLock)
+                (try-finally
+                  (begin
+                    (if (not embeddedChecked)
+                      (begin
+                        (set! embeddedAvailable
+                          (embeddedRulesAvailable
+                            (fileAbsolutePath
+                              (contextCodeCacheDir contextValue))))
+                        (set! embeddedError
+                          (if embeddedAvailable
+                            (nullable-none String)
+                            (nullable-some (embeddedRulesLastError))))
+                        (set! embeddedChecked #t))
+                      (begin))
+                    embeddedAvailable)
+                  (lockRelease initializationLock)))))
           (def (writeEmbeddedClientLog (body : String)) : String
             (modifiers private)
             (let ((dir (fileResolve (contextFilesDir contextValue) "logs")))
diff --git a/tests/fixtures/native-packaging-app.ss b/tests/fixtures/native-packaging-app.ss
new file mode 100644
index 0000000..77f2789
--- /dev/null
+++ b/tests/fixtures/native-packaging-app.ss
@@ -0,0 +1,9 @@
+(import (jerboa prelude))
+
+(def app
+  '(android-app
+     (id "org.jerboa.nativepackaging")
+     (name "Native Packaging")
+     (extract-native-libs #t)
+     (keep-jni-debug-symbol "**/libembedded_program.so")
+     (screen Main (text "Native Packaging"))))
diff --git a/tests/fixtures/original-tactics-app.ss b/tests/fixtures/original-tactics-app.ss
index d538e16..f7078a1 100644
--- a/tests/fixtures/original-tactics-app.ss
+++ b/tests/fixtures/original-tactics-app.ss
@@ -7,6 +7,8 @@
      (compile-sdk 36)
      (min-sdk 26)
      (target-sdk 36)
+     (extract-native-libs #t)
+     (keep-jni-debug-symbol "**/liboriginaltactics_program.so")
      (uses-cleartext-traffic #t)
      (activity-screen-orientation "landscape")
      (activity-config-changes "keyboardHidden|orientation|screenSize")