Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions apps/expo-glyphnet/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,7 @@
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
"foregroundImage": "./assets/images/adaptive-icon.png"
},
"predictiveBackGestureEnabled": false,
"package": "dev.triformine.glyphnet"
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/expo-glyphnet/assets/images/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
58 changes: 53 additions & 5 deletions apps/expo-glyphnet/scripts/eas-fetch-jni-libs.sh
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ if ! command -v unzip >/dev/null 2>&1; then
exit 0
fi

if command -v node >/dev/null 2>&1; then
JSON_EXTRACTOR="node"
elif command -v python3 >/dev/null 2>&1; then
JSON_EXTRACTOR="python3"
else
echo "[jni-fetch] neither node nor python3 available; skipping"
exit 0
fi

OWNER="${GLYPHNET_GITHUB_OWNER:-TriForMine}"
REPO="${GLYPHNET_GITHUB_REPO:-glyphnet}"
WORKFLOW_FILE="${GLYPHNET_JNI_WORKFLOW_FILE:-android-jni.yml}"
Expand All @@ -43,7 +52,11 @@ WORKFLOW_ID="$(
-H "${AUTH_HEADER}" \
-H "Accept: application/vnd.github+json" \
"${API_ROOT}/actions/workflows/${WORKFLOW_FILE}" \
| python -c "import sys, json; print(json.load(sys.stdin)['id'])"
| if [[ "${JSON_EXTRACTOR}" == "node" ]]; then
node -e "const fs=require('fs'); const d=JSON.parse(fs.readFileSync(0,'utf8')); console.log(d.id ?? '');"
else
python3 -c "import sys, json; print(json.load(sys.stdin).get('id',''))"
fi
)"

echo "[jni-fetch] resolving latest successful workflow run"
Expand All @@ -52,7 +65,11 @@ RUN_ID="$(
-H "${AUTH_HEADER}" \
-H "Accept: application/vnd.github+json" \
"${API_ROOT}/actions/workflows/${WORKFLOW_ID}/runs?status=success&per_page=1" \
| python -c "import sys, json; d=json.load(sys.stdin); runs=d.get('workflow_runs', []); print(runs[0]['id'] if runs else '')"
| if [[ "${JSON_EXTRACTOR}" == "node" ]]; then
node -e "const fs=require('fs'); const d=JSON.parse(fs.readFileSync(0,'utf8')); const runs=d.workflow_runs||[]; console.log(runs.length ? (runs[0].id ?? '') : '');"
else
python3 -c "import sys, json; d=json.load(sys.stdin); runs=d.get('workflow_runs', []); print(runs[0]['id'] if runs else '')"
fi
)"

if [[ -z "${RUN_ID}" ]]; then
Expand All @@ -66,7 +83,11 @@ ARTIFACT_ID="$(
-H "${AUTH_HEADER}" \
-H "Accept: application/vnd.github+json" \
"${API_ROOT}/actions/runs/${RUN_ID}/artifacts?per_page=100" \
| python -c "import sys, json; d=json.load(sys.stdin); arts=d.get('artifacts', []); name='${ARTIFACT_NAME}'; m=[a for a in arts if a.get('name')==name and not a.get('expired', False)]; print(m[0]['id'] if m else '')"
| if [[ "${JSON_EXTRACTOR}" == "node" ]]; then
node -e "const fs=require('fs'); const d=JSON.parse(fs.readFileSync(0,'utf8')); const arts=d.artifacts||[]; const name=process.argv[1]; const m=arts.find(a => a && a.name===name && !a.expired); console.log(m ? (m.id ?? '') : '');" "${ARTIFACT_NAME}"
else
python3 -c "import sys, json; d=json.load(sys.stdin); arts=d.get('artifacts', []); name='${ARTIFACT_NAME}'; m=[a for a in arts if a.get('name')==name and not a.get('expired', False)]; print(m[0]['id'] if m else '')"
fi
)"

if [[ -z "${ARTIFACT_ID}" ]]; then
Expand All @@ -85,9 +106,36 @@ curl -fL \
-o "${TMP_ZIP}"

unzip -q "${TMP_ZIP}" -d "${TMP_DIR}"

ABI_DIRS=("arm64-v8a" "armeabi-v7a" "x86_64")
SOURCE_ROOT=""
for candidate in \
"${TMP_DIR}" \
"${TMP_DIR}/jniLibs" \
"${TMP_DIR}/out/jniLibs"
do
if [[ -d "${candidate}" ]]; then
for abi in "${ABI_DIRS[@]}"; do
if [[ -d "${candidate}/${abi}" ]]; then
SOURCE_ROOT="${candidate}"
break 2
fi
done
fi
done

if [[ -z "${SOURCE_ROOT}" ]]; then
echo "[jni-fetch] unable to find ABI folders in artifact; aborting"
find "${TMP_DIR}" -maxdepth 3 -type d | sed 's/^/[jni-fetch] found dir: /'
exit 1
fi

mkdir -p "${DEST_DIR}"
rm -rf "${DEST_DIR:?}"/*
cp -R "${TMP_DIR}/"*/. "${DEST_DIR}/"
for abi in "${ABI_DIRS[@]}"; do
if [[ -d "${SOURCE_ROOT}/${abi}" ]]; then
cp -R "${SOURCE_ROOT}/${abi}" "${DEST_DIR}/"
fi
done

echo "[jni-fetch] installed JNI libs into ${DEST_DIR}"

4 changes: 4 additions & 0 deletions apps/expo-glyphnet/src/adapters/scanner/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ export interface ScanRequest {
mode: ScanMode;
verifyKeyHex?: string;
verifyKeyId?: number;
imageBase64?: string;
width?: number;
height?: number;
rgbaBase64?: string;
}

export interface ScannerAdapter {
Expand Down
31 changes: 26 additions & 5 deletions apps/expo-glyphnet/src/features/encode/EncodePanel.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,34 @@
import { useMemo, useState } from "react";

import { scannerAdapter } from "@/adapters/scanner";
import { Image } from "@/tw/image";
import { Pressable, Text, TextInput, View } from "@/tw";

export function EncodePanel() {
const [payload, setPayload] = useState("hello glyphnet");
const [svgPreview, setSvgPreview] = useState("");
const [encodeError, setEncodeError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);

const payloadBytes = useMemo(() => new TextEncoder().encode(payload).length, [payload]);

const runEncode = async () => {
setLoading(true);
try {
setEncodeError(null);
const svg = await scannerAdapter.encodeSvg(payload);
setSvgPreview(svg);
if (svg.trim().startsWith("<svg")) {
setSvgPreview(svg);
} else {
try {
const parsed = JSON.parse(svg) as { error?: string };
setSvgPreview("");
setEncodeError(parsed.error ?? "encode_failed");
} catch {
setSvgPreview("");
setEncodeError("encode_failed");
}
}
} finally {
setLoading(false);
}
Expand Down Expand Up @@ -54,11 +68,18 @@ export function EncodePanel() {
<Text className="text-xs font-semibold uppercase tracking-wide text-slate-400">
SVG output
</Text>
<Text selectable className="mt-2 font-mono text-xs leading-5 text-slate-100">
{svgPreview || "No output yet"}
</Text>
{svgPreview ? (
<Image
source={{ uri: `data:image/svg+xml;utf8,${encodeURIComponent(svgPreview)}` }}
contentFit="contain"
style={{ width: "100%", height: 220, marginTop: 10, borderRadius: 12 }}
/>
) : (
<Text selectable className="mt-2 font-mono text-xs leading-5 text-slate-100">
{encodeError ?? "No output yet"}
</Text>
)}
</View>
</View>
);
}

22 changes: 20 additions & 2 deletions apps/expo-glyphnet/src/features/scan/ScanPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { CameraView, useCameraPermissions } from "expo-camera";
import { useMemo, useState } from "react";
import { useMemo, useRef, useState } from "react";

import { scannerAdapter } from "@/adapters/scanner";
import { Pressable, Text, TextInput, View } from "@/tw";

const MODES = ["print", "screen", "burst"] as const;

export function ScanPanel() {
const cameraRef = useRef<any>(null);
const [permission, requestPermission] = useCameraPermissions();
const [mode, setMode] = useState<(typeof MODES)[number]>("print");
const [verifyKeyHex, setVerifyKeyHex] = useState("");
Expand All @@ -25,10 +26,23 @@ export function ScanPanel() {
const runScan = async () => {
setLoading(true);
try {
if (!cameraRef.current?.takePictureAsync) {
setResult("Camera capture is not ready yet.");
return;
}
const shot = await cameraRef.current.takePictureAsync({
base64: true,
quality: 0.9,
});
if (!shot?.base64) {
setResult("Failed to capture image from camera.");
return;
}
const json = await scannerAdapter.scanStill({
mode,
verifyKeyHex: verifyKeyHex || undefined,
verifyKeyId: 1,
imageBase64: shot.base64,
});
setResult(JSON.stringify(json, null, 2));
} finally {
Expand All @@ -44,7 +58,11 @@ export function ScanPanel() {
</Text>
<View className="mt-3 h-56 overflow-hidden rounded-2xl bg-slate-200 dark:bg-neutral-800">
{permission?.granted ? (
<CameraView style={{ width: "100%", height: "100%" }} facing="back" />
<CameraView
ref={cameraRef}
style={{ width: "100%", height: "100%" }}
facing="back"
/>
) : (
<View className="flex-1 items-center justify-center px-6">
<Text className="text-center text-sm text-slate-600 dark:text-neutral-300">
Expand Down
145 changes: 88 additions & 57 deletions crates/glyphnet-jni/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ struct ScanStillRequest {
height: Option<u32>,
#[serde(rename = "rgbaBase64")]
rgba_base64: Option<String>,
#[serde(rename = "imageBase64")]
image_base64: Option<String>,
}

fn make_java_string(env: JNIEnv<'_>, value: &str) -> jstring {
Expand Down Expand Up @@ -131,74 +133,103 @@ pub extern "system" fn Java_expo_modules_glyphnetscanner_GlyphNetNativeBridge_sc
let verify_key_provided = parsed.verify_key_hex.is_some();
let verify_key_id = parsed.verify_key_id;

let width = match parsed.width {
Some(v) => v,
None => {
let err = error_json(mode, verify_key_provided, verify_key_id, "missing_width");
return make_java_string(env, &err);
}
};
let height = match parsed.height {
Some(v) => v,
None => {
let err = error_json(mode, verify_key_provided, verify_key_id, "missing_height");
return make_java_string(env, &err);
}
};
let rgba_base64 = match parsed.rgba_base64 {
Some(v) => v,
None => {
let err = error_json(
mode,
verify_key_provided,
verify_key_id,
"missing_rgba_base64",
);
return make_java_string(env, &err);
}
};
let image = if let Some(image_base64) = parsed.image_base64 {
let bytes = match base64::engine::general_purpose::STANDARD.decode(image_base64.as_bytes())
{
Ok(v) => v,
Err(error) => {
let err = error_json(
mode,
verify_key_provided,
verify_key_id,
&format!("invalid_image_base64: {error}"),
);
return make_java_string(env, &err);
}
};

let rgba = match base64::engine::general_purpose::STANDARD.decode(rgba_base64.as_bytes()) {
Ok(v) => v,
Err(error) => {
let err = error_json(
mode,
verify_key_provided,
verify_key_id,
&format!("invalid_rgba_base64: {error}"),
);
return make_java_string(env, &err);
match image::load_from_memory(&bytes) {
Ok(v) => v,
Err(error) => {
let err = error_json(
mode,
verify_key_provided,
verify_key_id,
&format!("invalid_image_data: {error}"),
);
return make_java_string(env, &err);
}
}
};
} else {
let width = match parsed.width {
Some(v) => v,
None => {
let err = error_json(mode, verify_key_provided, verify_key_id, "missing_width");
return make_java_string(env, &err);
}
};
let height = match parsed.height {
Some(v) => v,
None => {
let err = error_json(mode, verify_key_provided, verify_key_id, "missing_height");
return make_java_string(env, &err);
}
};
let rgba_base64 = match parsed.rgba_base64 {
Some(v) => v,
None => {
let err = error_json(
mode,
verify_key_provided,
verify_key_id,
"missing_rgba_base64",
);
return make_java_string(env, &err);
}
};

let expected = width
.checked_mul(height)
.and_then(|pixels| pixels.checked_mul(4))
.unwrap_or(0) as usize;
if rgba.len() != expected {
let err = error_json(
mode,
verify_key_provided,
verify_key_id,
&format!(
"invalid_rgba_length: expected {expected}, got {}",
rgba.len()
),
);
return make_java_string(env, &err);
}
let rgba = match base64::engine::general_purpose::STANDARD.decode(rgba_base64.as_bytes()) {
Ok(v) => v,
Err(error) => {
let err = error_json(
mode,
verify_key_provided,
verify_key_id,
&format!("invalid_rgba_base64: {error}"),
);
return make_java_string(env, &err);
}
};

let image = match RgbaImage::from_raw(width, height, rgba) {
Some(v) => DynamicImage::ImageRgba8(v),
None => {
let expected = width
.checked_mul(height)
.and_then(|pixels| pixels.checked_mul(4))
.unwrap_or(0) as usize;
if rgba.len() != expected {
let err = error_json(
mode,
verify_key_provided,
verify_key_id,
"failed_to_construct_rgba_image",
&format!(
"invalid_rgba_length: expected {expected}, got {}",
rgba.len()
),
);
return make_java_string(env, &err);
}

match RgbaImage::from_raw(width, height, rgba) {
Some(v) => DynamicImage::ImageRgba8(v),
None => {
let err = error_json(
mode,
verify_key_provided,
verify_key_id,
"failed_to_construct_rgba_image",
);
return make_java_string(env, &err);
}
}
};

let response = match scan_still_with_diagnostics(&image, mode) {
Expand Down
Loading